From b75ed4d157061bef20c4c49cba06cae3d41964a8 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Wed, 30 Sep 2026 16:43:19 +0200 Subject: [PATCH 01/10] Keep the SDK working on devices without Play Billing GoogleBillingWrapper now remembers when billing is unavailable and fails requests straight away instead of reconnecting for each one. A billing client that can't be created marks billing unavailable rather than leaving queued requests hanging, and availability is probed again whenever the app returns to the foreground. BillingNotAvailable is no longer cached per product for the life of the process. StoreManager applies a single rule when billing is unavailable: a paywall presents with whatever resolved without Play (test, custom and substitute products) and only fails when nothing resolved and test mode is off. AutomaticPurchaseController no longer throws from its constructor when the billing client can't be created, and purchase() fails instead of waiting forever when the connection can't be established. Co-Authored-By: Claude Opus 5.5 --- .../sdk/billing/BillingAvailability.kt | 20 ++ .../sdk/billing/GoogleBillingWrapper.kt | 65 ++++- .../sdk/store/AutomaticPurchaseController.kt | 71 +++-- .../com/superwall/sdk/store/StoreManager.kt | 47 ++-- .../GoogleBillingWrapperAvailabilityTest.kt | 250 ++++++++++++++++++ .../store/AutomaticPurchaseControllerTest.kt | 93 +++++++ .../superwall/sdk/store/StoreManagerTest.kt | 53 ++++ ...stModeBillingUnavailableIntegrationTest.kt | 2 +- 8 files changed, 547 insertions(+), 54 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt create mode 100644 superwall/src/test/java/com/superwall/sdk/store/AutomaticPurchaseControllerTest.kt diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt new file mode 100644 index 000000000..a2a391e00 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt @@ -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. + */ +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 +} diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt index fc547d566..0526bac87 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt @@ -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 @@ -101,6 +102,15 @@ class GoogleBillingWrapper( @set:Synchronized private var reconnectionAlreadyScheduled = false + private val _availability = MutableStateFlow(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. + */ + val availability = _availability.asStateFlow() + // Setup mutable state flow for purchase results override val purchaseResults = MutableStateFlow(null) @@ -109,6 +119,19 @@ 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 + startConnection() + } + } + } } internal class Handler( @@ -148,14 +171,16 @@ class GoogleBillingWrapper( } } - override suspend fun queryAllPurchases(): List = - coroutineScope { + override suspend fun queryAllPurchases(): List { + 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 = suspendCoroutine { cont -> @@ -187,7 +212,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 @@ -275,15 +311,8 @@ 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. + // BillingNotAvailable is remembered in [availability] instead. continuation.resumeWithException(error) } }, @@ -411,6 +440,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() @@ -508,6 +541,7 @@ class GoogleBillingWrapper( LogScope.productsManager, "Billing client connected", ) + _availability.value = BillingAvailability.Available executePendingRequests() reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS trackProductDetailsNotSupportedIfNeeded() @@ -554,7 +588,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, @@ -682,6 +716,11 @@ class GoogleBillingWrapper( } } + private fun markUnavailable(error: BillingError.BillingNotAvailable) { + _availability.value = BillingAvailability.Unavailable(error) + sendErrorsToAllPendingRequests(error) + } + @Synchronized private fun sendErrorsToAllPendingRequests(error: BillingError) { while (true) { diff --git a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt index 77ba72ce5..2c6ef9729 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/AutomaticPurchaseController.kt @@ -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, @@ -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 @@ -102,6 +106,12 @@ class AutomaticPurchaseController( } private fun startConnection() { + val billingClient = + billingClient ?: run { + connectionState.value = ConnectionState.Failed + syncSubscriptionStatus() + return + } try { billingClient.startConnection( object : BillingClientStateListener { @@ -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 @@ -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 @@ -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 @@ -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) { @@ -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, diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index 698e6bc4f..7c32c76c7 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -69,6 +69,13 @@ class StoreManager( val productItems: List, ) + private class FetchedProducts( + val products: Map, + // Set when Play Billing can't be used on this device. [products] then holds + // whatever resolved without it. + val billingUnavailable: BillingError.BillingNotAvailable? = null, + ) + override suspend fun getProductVariables( paywall: Paywall, request: PaywallRequest, @@ -115,10 +122,9 @@ class StoreManager( // Try Play Billing first so Play-only lookups never pay for a /products round-trip. val billingError = try { - for ((id, product) in fetchOrAwaitProducts(processingResult.fullProductIdsToLoad)) { - productsById[id] = product - } - null + val fetched = fetchOrAwaitProducts(processingResult.fullProductIdsToLoad) + productsById.putAll(fetched.products) + fetched.billingUnavailable } catch (e: Throwable) { e } @@ -208,9 +214,14 @@ class StoreManager( val productsById = processingResult.substituteProductsById.toMutableMap() try { - val fetchResult = fetchOrAwaitProducts(processingResult.fullProductIdsToLoad) - for ((id, product) in fetchResult) { - productsById[id] = product + val fetched = fetchOrAwaitProducts(processingResult.fullProductIdsToLoad) + productsById.putAll(fetched.products) + + // Without Play Billing a paywall can still present whatever resolved elsewhere + // (test, custom and substitute products). Only fail when there's nothing to + // show, and never in test mode. + fetched.billingUnavailable?.let { error -> + if (productsById.isEmpty() && testMode?.isTestMode != true) throw error } } catch (error: Throwable) { paywall.productsLoadingInfo.failAt = Date() @@ -223,8 +234,8 @@ class StoreManager( ) track(productLoadEvent) - // If billing isn't available, make it call the onError handler when requesting - // a paywall. + // If billing isn't available and nothing resolved, make it call the onError + // handler when requesting a paywall. if (error is BillingError.BillingNotAvailable) { throw error } @@ -237,7 +248,7 @@ class StoreManager( ) } - private suspend fun fetchOrAwaitProducts(fullProductIds: Set): Map { + private suspend fun fetchOrAwaitProducts(fullProductIds: Set): FetchedProducts { val activeTestMode = testMode?.takeIf { it.isTestMode } activeTestMode?.awaitTestProducts() val testProducts = activeTestMode?.testProductsByFullId.orEmpty() @@ -248,7 +259,7 @@ class StoreManager( fullProductIds.mapNotNull { id -> testProducts[id]?.let { id to it } }.toMap() } val remainingIds = fullProductIds - testHits.keys - if (remainingIds.isEmpty()) return testHits + if (remainingIds.isEmpty()) return FetchedProducts(testHits) val cached = mutableMapOf() val loading = mutableListOf>() @@ -293,8 +304,8 @@ class StoreManager( productsByFullId[id] = ProductState.Error(e) deferred.completeExceptionally(e) } - if (activeTestMode != null && e is BillingError.BillingNotAvailable) { - return testHits + cached + if (e is BillingError.BillingNotAvailable) { + return FetchedProducts(testHits + cached, billingUnavailable = e) } throw e } @@ -302,15 +313,11 @@ class StoreManager( val fetched = try { fetchNewProducts(newDeferreds) - } catch (e: Throwable) { - if (activeTestMode != null && e is BillingError.BillingNotAvailable) { - emptyMap() - } else { - throw e - } + } catch (e: BillingError.BillingNotAvailable) { + return FetchedProducts(testHits + cached + awaited, billingUnavailable = e) } - return testHits + cached + awaited + fetched + return FetchedProducts(testHits + cached + awaited + fetched) } private suspend fun fetchNewProducts(deferreds: Map>): Map { diff --git a/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt new file mode 100644 index 000000000..eb1f5e438 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt @@ -0,0 +1,250 @@ +@file:Suppress("ktlint:standard:function-naming") + +package com.superwall.sdk.billing + +import androidx.lifecycle.LifecycleOwner +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.misc.AppLifecycleObserver +import com.superwall.sdk.misc.IOScope +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GoogleBillingWrapperAvailabilityTest { + private val productId = "product1:basePlan1:sw-auto" + private val lifecycleObserver = AppLifecycleObserver() + + @Before + fun setup() { + GoogleBillingWrapper.clearProductsCache() + } + + @After + fun tearDown() { + GoogleBillingWrapper.clearProductsCache() + } + + private fun billingResult(code: Int): BillingResult = + BillingResult + .newBuilder() + .setResponseCode(code) + .setDebugMessage("") + .build() + + private fun disconnectedClient(): BillingClient = + mockk(relaxed = true) { + every { isReady } returns false + } + + private fun TestScope.makeWrapper(createBillingClient: () -> BillingClient): GoogleBillingWrapper { + val factory = + mockk { + every { makeHasExternalPurchaseController() } returns false + every { makeHasInternalPurchaseController() } returns false + every { makeSuperwallOptions() } returns SuperwallOptions() + } + return GoogleBillingWrapper( + context = mockk(relaxed = true), + ioScope = IOScope(UnconfinedTestDispatcher(testScheduler)), + appLifecycleObserver = lifecycleObserver, + factory = factory, + createBillingClient = { createBillingClient() }, + ) + } + + private fun TestScope.makeUnavailableWrapper(client: BillingClient): GoogleBillingWrapper { + val wrapper = makeWrapper { client } + runCurrent() + wrapper.onBillingSetupFinished( + billingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE), + ) + runCurrent() + return wrapper + } + + @Test + fun `availability is unknown until the first connection attempt resolves`() = + runTest { + Given("a wrapper whose billing client hasn't finished setup") { + val wrapper = makeWrapper { disconnectedClient() } + runCurrent() + + Then("availability is unknown") { + assertEquals(BillingAvailability.Unknown, wrapper.availability.value) + } + + When("setup finishes OK") { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.OK)) + runCurrent() + + Then("billing is available") { + assertEquals(BillingAvailability.Available, wrapper.availability.value) + } + } + } + } + + @Test + fun `requests fail fast without reconnecting once billing is unavailable`() = + runTest { + Given("a device where billing setup reported BILLING_UNAVAILABLE") { + val client = disconnectedClient() + val wrapper = makeUnavailableWrapper(client) + + Then("availability is unavailable") { + assertTrue(wrapper.availability.value is BillingAvailability.Unavailable) + } + + When("products are requested") { + val job = backgroundScope.async { runCatching { wrapper.awaitGetProducts(setOf(productId)) } } + runCurrent() + + Then("the request fails with BillingNotAvailable") { + assertTrue(job.isCompleted) + assertTrue(job.await().exceptionOrNull() is BillingError.BillingNotAvailable) + } + + And("the billing client was not asked to connect again") { + verify(exactly = 1) { client.startConnection(any()) } + } + } + } + } + + @Test + fun `requests fail instead of hanging when the billing client can't be created`() = + runTest { + Given("a device where creating the billing client throws") { + val wrapper = makeWrapper { throw IllegalStateException("No Play Store") } + runCurrent() + + Then("availability is unavailable") { + assertTrue(wrapper.availability.value is BillingAvailability.Unavailable) + } + + When("products are requested") { + val job = backgroundScope.async { runCatching { wrapper.awaitGetProducts(setOf(productId)) } } + runCurrent() + + Then("the request fails with BillingNotAvailable") { + assertTrue(job.isCompleted) + assertTrue(job.await().exceptionOrNull() is BillingError.BillingNotAvailable) + } + } + } + } + + @Test + fun `requests queued before the billing client fails to create are failed`() = + runTest { + Given("a billing client that fails to create after a request was queued") { + var shouldThrow = false + val wrapper = + makeWrapper { + if (shouldThrow) throw IllegalStateException("No Play Store") + disconnectedClient() + } + runCurrent() + wrapper.billingClient = null + shouldThrow = true + + When("products are requested and the connection is retried") { + val job = backgroundScope.async { runCatching { wrapper.awaitGetProducts(setOf(productId)) } } + runCurrent() + wrapper.startConnection() + runCurrent() + + Then("the queued request fails with BillingNotAvailable") { + assertTrue(job.isCompleted) + assertTrue(job.await().exceptionOrNull() is BillingError.BillingNotAvailable) + } + } + } + } + + @Test + fun `purchases query returns empty without retrying once billing is unavailable`() = + runTest { + Given("a device where billing is unavailable") { + val wrapper = makeUnavailableWrapper(disconnectedClient()) + + When("all purchases are queried") { + val purchases = wrapper.queryAllPurchases() + + Then("it returns empty without waiting on retries") { + assertTrue(purchases.isEmpty()) + assertEquals(0L, testScheduler.currentTime) + } + } + } + } + + @Test + fun `billing is probed again when the app returns to the foreground`() = + runTest { + Given("a device where billing is unavailable") { + val client = disconnectedClient() + val wrapper = makeUnavailableWrapper(client) + val owner = mockk(relaxed = true) + + When("the app comes back to the foreground") { + lifecycleObserver.onStop(owner) + lifecycleObserver.onStart(owner) + runCurrent() + + Then("the billing client is asked to connect again") { + verify(exactly = 2) { client.startConnection(any()) } + assertEquals(BillingAvailability.Unknown, wrapper.availability.value) + } + + And("a successful setup makes billing available") { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.OK)) + runCurrent() + assertEquals(BillingAvailability.Available, wrapper.availability.value) + } + } + } + } + + @Test + fun `a failed product load is not remembered once billing becomes available`() = + runTest { + Given("a product load that failed while billing was unavailable") { + val client = disconnectedClient() + val wrapper = makeUnavailableWrapper(client) + val failed = backgroundScope.async { runCatching { wrapper.awaitGetProducts(setOf(productId)) } } + runCurrent() + assertTrue(failed.await().isFailure) + + When("billing becomes available and the product is requested again") { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.OK)) + runCurrent() + val retry = backgroundScope.async { runCatching { wrapper.awaitGetProducts(setOf(productId)) } } + runCurrent() + + Then("the request goes to billing instead of failing from cache") { + assertTrue(!retry.isCompleted) + verify(atLeast = 2) { client.startConnection(any()) } + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/store/AutomaticPurchaseControllerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/AutomaticPurchaseControllerTest.kt new file mode 100644 index 000000000..982e9cce9 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/store/AutomaticPurchaseControllerTest.kt @@ -0,0 +1,93 @@ +@file:Suppress("ktlint:standard:function-naming") + +package com.superwall.sdk.store + +import android.app.Activity +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ProductDetails +import com.superwall.sdk.And +import com.superwall.sdk.Given +import com.superwall.sdk.Then +import com.superwall.sdk.When +import com.superwall.sdk.delegate.PurchaseResult +import com.superwall.sdk.misc.IOScope +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AutomaticPurchaseControllerTest { + private fun TestScope.makeController(getBilling: () -> BillingClient): AutomaticPurchaseController = + AutomaticPurchaseController( + context = mockk(relaxed = true), + scope = IOScope(UnconfinedTestDispatcher(testScheduler)), + entitlementsInfo = { mockk(relaxed = true) }, + getBilling = { _, _ -> getBilling() }, + ) + + private fun clientFinishingSetupWith(code: Int): BillingClient = + mockk(relaxed = true) { + every { isReady } returns false + every { startConnection(any()) } answers { + firstArg().onBillingSetupFinished( + BillingResult + .newBuilder() + .setResponseCode(code) + .setDebugMessage("") + .build(), + ) + } + } + + @Test + fun `purchase fails instead of throwing or hanging when the billing client can't be created`() = + runTest { + Given("a device where creating the billing client throws") { + val controller = makeController { throw IllegalStateException("No Play Store") } + advanceUntilIdle() + + When("a purchase is attempted") { + @Suppress("DEPRECATION") + val result = controller.purchase(mockk(relaxed = true), mockk(relaxed = true), null, null) + + Then("it fails") { + assertTrue(result is PurchaseResult.Failed) + } + } + } + } + + @Test + fun `purchase fails after one more connection attempt when billing is unavailable`() = + runTest { + Given("a device where billing setup reports BILLING_UNAVAILABLE") { + val client = clientFinishingSetupWith(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) + val controller = makeController { client } + advanceUntilIdle() + + When("a purchase is attempted") { + @Suppress("DEPRECATION") + val result = controller.purchase(mockk(relaxed = true), mockk(relaxed = true), null, null) + + Then("it fails") { + assertTrue(result is PurchaseResult.Failed) + } + + And("the connection was retried once for the purchase") { + verify(exactly = 2) { client.startConnection(any()) } + assertEquals(0L, testScheduler.currentTime) + } + } + } + } +} diff --git a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt index 5e8403f16..27f7632b7 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt @@ -828,6 +828,59 @@ class StoreManagerTest { } } + @Test + fun `getProducts presents a mixed paywall with its custom products when billing is unavailable`() = + runTest { + Given("a paywall mixing a custom and a Play product on a device without billing") { + val manager = storeManagerWith { Either.Success(customProductsResponse("custom_1")) } + manager.fetchAndCacheCustomProducts(setOf("custom_1"), required = true) + coEvery { billing.awaitGetProducts(any()) } throws BillingError.BillingNotAvailable("nope") + val paywall = + Paywall.stub().copy( + productIds = listOf("custom_1", "product1:basePlan1:sw-auto"), + ) + + When("getProducts is called outside of test mode") { + val result = manager.getProducts(null, paywall, null) + + Then("it returns the custom product instead of throwing") { + assertEquals(setOf("custom_1"), result.productsByFullId.keys) + } + + And("the load is not marked as failed") { + assertNull(paywall.productsLoadingInfo.failAt) + } + } + } + } + + @Test + fun `getProducts in test mode presents a Play-only paywall even when nothing resolves`() = + runTest { + Given("test mode is active with a catalog that doesn't cover the paywall and billing is unavailable") { + val testMode = makeActiveTestMode() + testMode.setTestProducts( + mapOf( + "other:basePlan1:sw-auto" to + mockk { + every { fullIdentifier } returns "other:basePlan1:sw-auto" + }, + ), + ) + storeManager.testMode = testMode + coEvery { billing.awaitGetProducts(any()) } throws + BillingError.BillingNotAvailable("Billing not available") + + When("getProducts is called") { + val result = storeManager.getProducts(null, makePaywallWithTwoProducts(), null) + + Then("it returns no products instead of throwing") { + junitAssertTrue(result.productsByFullId.isEmpty()) + } + } + } + } + @Test fun `fetchAndCacheCustomProducts rethrows on products failure when required`() = runTest { diff --git a/superwall/src/test/java/com/superwall/sdk/store/TestModeBillingUnavailableIntegrationTest.kt b/superwall/src/test/java/com/superwall/sdk/store/TestModeBillingUnavailableIntegrationTest.kt index b05f0eeba..ca6588e71 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/TestModeBillingUnavailableIntegrationTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/TestModeBillingUnavailableIntegrationTest.kt @@ -196,7 +196,7 @@ class TestModeBillingUnavailableIntegrationTest { ) } - And("a second load succeeds despite the permanently cached billing failure") { + And("a second load succeeds now that billing is known to be unavailable") { val job2 = async { storeManager.getProducts(null, makePaywall(), null) } advanceUntilIdle() assertEquals( From a1d31a2cc894b747a1850ebf44549da951912adc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 30 Sep 2026 15:04:05 +0000 Subject: [PATCH 02/10] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index ebe2b57eb..ac08ba372 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.8% \ No newline at end of file +branches38.9% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 302e4d57d..c61b21490 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.7% \ No newline at end of file +coverage48% \ No newline at end of file From f92e497605fa32c8805a6de319b91fcf6a1bf825 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 1 Oct 2026 14:33:58 +0200 Subject: [PATCH 03/10] Fix square corners showing behind test mode sheets The test mode modal and the purchase and restore drawers put a rounded background on their content, but BottomSheetDialog wraps it in a sheet container with its own opaque background, whose square corners showed behind the rounded ones. Make that container transparent. Co-Authored-By: Claude Opus 5.5 --- .../store/testmode/ui/BottomSheetDialogExt.kt | 16 ++++++++++++++++ .../sdk/store/testmode/ui/TestModeModal.kt | 2 +- .../store/testmode/ui/TestModePurchaseDrawer.kt | 2 +- .../store/testmode/ui/TestModeRestoreDrawer.kt | 2 +- 4 files changed, 19 insertions(+), 3 deletions(-) create mode 100644 superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt new file mode 100644 index 000000000..435af3b7b --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.store.testmode.ui + +import android.graphics.Color +import android.view.View +import com.google.android.material.bottomsheet.BottomSheetDialog + +/** + * Sets [view] as the sheet's content. The sheet container Material wraps it in has its own + * opaque background, which would show square corners behind the view's rounded background, + * so the container is made transparent. + */ +internal fun BottomSheetDialog.setSheetContent(view: View) { + setContentView(view) + findViewById(com.google.android.material.R.id.design_bottom_sheet) + ?.setBackgroundColor(Color.TRANSPARENT) +} diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeModal.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeModal.kt index 28d59cd8b..4baedcb0e 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeModal.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeModal.kt @@ -173,7 +173,7 @@ internal object TestModeModal { dialog.dismiss() } - dialog.setContentView(view) + dialog.setSheetContent(view) dialog.setOnDismissListener { ioScope.cancel() if (!result.isCompleted) { diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModePurchaseDrawer.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModePurchaseDrawer.kt index 3b436a8bc..bad641986 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModePurchaseDrawer.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModePurchaseDrawer.kt @@ -80,7 +80,7 @@ internal object TestModePurchaseDrawer { dialog.dismiss() } - dialog.setContentView(view) + dialog.setSheetContent(view) dialog.setOnDismissListener { if (!result.isCompleted) { result.complete(PurchaseSimulationResult.Abandoned) diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeRestoreDrawer.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeRestoreDrawer.kt index fb615e23e..fed5e8b4f 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeRestoreDrawer.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/TestModeRestoreDrawer.kt @@ -74,7 +74,7 @@ internal object TestModeRestoreDrawer { dialog.dismiss() } - dialog.setContentView(view) + dialog.setSheetContent(view) dialog.setOnDismissListener { if (!result.isCompleted) { result.complete(RestoreSimulationResult.Cancelled) From c6a624112967db6926cb05f43bac85cb0481a7b4 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 1 Oct 2026 14:33:58 +0200 Subject: [PATCH 04/10] Add a test_app flow for test mode without Play Billing Adds a "Configure with test mode" option to the purchase controller test screen and a Maestro flow that configures in test mode, presents a Play paywall from the test catalog and completes a simulated purchase. The flow needs the Play Store disabled on the device, so it isn't part of the default flows in config.yaml. Co-Authored-By: Claude Opus 5.5 --- .../testmode/no_billing_test_mode.yaml | 42 +++++++++++++++++++ .../screens/PurchaseControllerTestScreen.kt | 19 +++++++++ 2 files changed, 61 insertions(+) create mode 100644 test_app/maestro/testmode/no_billing_test_mode.yaml diff --git a/test_app/maestro/testmode/no_billing_test_mode.yaml b/test_app/maestro/testmode/no_billing_test_mode.yaml new file mode 100644 index 000000000..ac21b8036 --- /dev/null +++ b/test_app/maestro/testmode/no_billing_test_mode.yaml @@ -0,0 +1,42 @@ +appId: com.superwall.superapp +name: no_billing_test_mode +tags: + - no_billing +--- +# Runs on a device without Google Play Billing: disable the Play Store first with +# adb shell pm disable-user --user 0 com.android.vending +# Not part of config.yaml's default flows, since it needs that device state. +- launchApp: + appId: com.superwall.superapp + clearState: true +- tapOn: "Purchase Controller Test" +- tapOn: "Configure with test mode" + +# Test mode should come up even though billing can't connect +- extendedWaitUntil: + visible: ".*Test Mode Active" + timeout: 20000 +- scrollUntilVisible: + element: "Continue" + direction: DOWN +- tapOn: "Continue" + +# A Play-only paywall presents with products from the test catalog +- tapOn: "Trigger Paywall" +- extendedWaitUntil: + visible: ".*Continue.*" + timeout: 20000 +- tapOn: "Continue" + +# Purchases go through the simulated test mode drawer, not Play +- extendedWaitUntil: + visible: "TEST MODE" + timeout: 10000 +- tapOn: "(Confirm Purchase|Start Free Trial)" + +# The simulated purchase unlocks the feature and activates the subscription +- extendedWaitUntil: + visible: "Feature triggered" + timeout: 10000 +- tapOn: "OK" +- assertVisible: "Subscription status is Active" diff --git a/test_app/src/main/java/com/superwall/superapp/ui/screens/PurchaseControllerTestScreen.kt b/test_app/src/main/java/com/superwall/superapp/ui/screens/PurchaseControllerTestScreen.kt index 64cc98c42..b4f09223a 100644 --- a/test_app/src/main/java/com/superwall/superapp/ui/screens/PurchaseControllerTestScreen.kt +++ b/test_app/src/main/java/com/superwall/superapp/ui/screens/PurchaseControllerTestScreen.kt @@ -17,6 +17,7 @@ import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.misc.ActivityProvider import com.superwall.sdk.models.entitlements.SubscriptionStatus import com.superwall.sdk.paywall.presentation.register +import com.superwall.sdk.store.testmode.TestModeBehavior import com.superwall.superapp.BuildConfig import com.superwall.superapp.purchase.TestingPurchaseController import kotlinx.coroutines.launch @@ -89,6 +90,22 @@ fun PurchaseControllerTestScreen(navController: NavController) { Text("Configure without PC") } + ElevatedButton( + onClick = { + scope.launch { + try { + configureWithoutPC(context, apiKey, testMode = true) + isConfigured = true + } catch (e: Exception) { + // Handle configuration error + } + } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Configure with test mode") + } + if (isConfigured) { Column { val status by Superwall.instance.subscriptionStatus.collectAsState() @@ -244,12 +261,14 @@ private suspend fun configureWithPC( private suspend fun configureWithoutPC( context: Context, apiKey: String, + testMode: Boolean = false, ) { val application = context.applicationContext as Application val options = SuperwallOptions().apply { logging.level = LogLevel.debug networkEnvironment = getNetworkEnvironment() + if (testMode) testModeBehavior = TestModeBehavior.ALWAYS } val activityProvider = object : ActivityProvider { From 2873e090c29d6ece16564651ea7450fea672d503 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 1 Oct 2026 14:45:37 +0200 Subject: [PATCH 05/10] Address review comments on billing availability - Log a warning listing the products a paywall presents without when billing is unavailable, so a missing price can be traced from logs. - Make BillingAvailability and GoogleBillingWrapper.availability internal. - Clarify that only setup-time unavailability is remembered. - Clear the test mode sheet container's background after its first layout, since BottomSheetBehavior swaps in its own background then on Material-themed hosts. - Rename the instrumented wrapper tests that still described the removed per-product failure cache. Co-Authored-By: Claude Opus 5.5 --- .../sdk/billing/GoogleBillingWrapperTest.kt | 18 +++++++++--------- .../sdk/billing/BillingAvailability.kt | 2 +- .../sdk/billing/GoogleBillingWrapper.kt | 7 ++++--- .../com/superwall/sdk/store/StoreManager.kt | 9 +++++++++ .../store/testmode/ui/BottomSheetDialogExt.kt | 6 ++++-- 5 files changed, 27 insertions(+), 15 deletions(-) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt index 03d7bd8b6..a6d70bff9 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/billing/GoogleBillingWrapperTest.kt @@ -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) @@ -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, ) } @@ -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) @@ -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, ) } @@ -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, ) } diff --git a/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt b/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt index a2a391e00..05ff1c7e0 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/BillingAvailability.kt @@ -4,7 +4,7 @@ package com.superwall.sdk.billing * Whether Google Play Billing can be used on this device, as learned from the billing * client's connection attempts. */ -sealed interface BillingAvailability { +internal sealed interface BillingAvailability { /** No connection attempt has resolved yet. */ object Unknown : BillingAvailability diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt index 0526bac87..e2dbaa379 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt @@ -109,7 +109,7 @@ class GoogleBillingWrapper( * requests fail straight away instead of reconnecting, until the app next returns to * the foreground and billing is probed again. */ - val availability = _availability.asStateFlow() + internal val availability = _availability.asStateFlow() // Setup mutable state flow for purchase results override val purchaseResults = MutableStateFlow(null) @@ -311,8 +311,9 @@ class GoogleBillingWrapper( } override fun onError(error: BillingError) { - // Billing errors aren't cached so a later request can retry. - // BillingNotAvailable is remembered in [availability] instead. + // 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) } }, diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index 7c32c76c7..94a120550 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -222,6 +222,15 @@ class StoreManager( // show, and never in test mode. fetched.billingUnavailable?.let { error -> if (productsById.isEmpty() && testMode?.isTestMode != true) throw error + val unresolved = processingResult.fullProductIdsToLoad - productsById.keys + if (unresolved.isNotEmpty()) { + Logger.debug( + LogLevel.warn, + LogScope.productsManager, + "Google Play Billing is not available, presenting the paywall without " + + "products: ${unresolved.joinToString()}", + ) + } } } catch (error: Throwable) { paywall.productsLoadingInfo.failAt = Date() diff --git a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt index 435af3b7b..213a80903 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt @@ -2,15 +2,17 @@ package com.superwall.sdk.store.testmode.ui import android.graphics.Color import android.view.View +import androidx.core.view.doOnLayout import com.google.android.material.bottomsheet.BottomSheetDialog /** * Sets [view] as the sheet's content. The sheet container Material wraps it in has its own * opaque background, which would show square corners behind the view's rounded background, - * so the container is made transparent. + * so the container is made transparent. That happens after its first layout, as + * BottomSheetBehavior swaps in its own background then on Material-themed hosts. */ internal fun BottomSheetDialog.setSheetContent(view: View) { setContentView(view) findViewById(com.google.android.material.R.id.design_bottom_sheet) - ?.setBackgroundColor(Color.TRANSPARENT) + ?.doOnLayout { it.setBackgroundColor(Color.TRANSPARENT) } } From 4bac825b1207009e60fa904391b2d18b40dd8f80 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 1 Oct 2026 14:45:37 +0200 Subject: [PATCH 06/10] Treat repeated transient billing setup failures as unavailable On devices with a broken Play Store (e.g. microG-based emulators), billing setup keeps returning ERROR instead of BILLING_UNAVAILABLE. That was retried forever, so product requests queued behind the connection and a paywall load never finished. After three transient setup failures in a row, billing is now marked unavailable so waiting requests fail. Reconnect attempts carry on and a later successful setup makes billing available again. Co-Authored-By: Claude Opus 5.5 --- .../sdk/billing/GoogleBillingWrapper.kt | 21 ++++++ .../GoogleBillingWrapperAvailabilityTest.kt | 66 +++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt index e2dbaa379..2c509efa1 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt @@ -51,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, @@ -102,6 +107,8 @@ class GoogleBillingWrapper( @set:Synchronized private var reconnectionAlreadyScheduled = false + private val transientSetupFailures = AtomicInteger(0) + private val _availability = MutableStateFlow(BillingAvailability.Unknown) /** @@ -128,6 +135,7 @@ class GoogleBillingWrapper( .collect { if (availability.value is BillingAvailability.Unavailable) { _availability.value = BillingAvailability.Unknown + transientSetupFailures.set(0) startConnection() } } @@ -545,6 +553,7 @@ class GoogleBillingWrapper( _availability.value = BillingAvailability.Available executePendingRequests() reconnectMilliseconds = RECONNECT_TIMER_START_MILLISECONDS + transientSetupFailures.set(0) trackProductDetailsNotSupportedIfNeeded() } @@ -603,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() } diff --git a/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt b/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt index eb1f5e438..6203192d6 100644 --- a/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt @@ -247,4 +247,70 @@ class GoogleBillingWrapperAvailabilityTest { } } } + + @Test + fun `repeated transient setup failures mark billing unavailable and fail waiting requests`() = + runTest { + Given("a device whose billing setup keeps failing with ERROR") { + val wrapper = makeWrapper { disconnectedClient() } + runCurrent() + val job = backgroundScope.async { runCatching { wrapper.awaitGetProducts(setOf(productId)) } } + runCurrent() + + When("setup fails fewer times than the limit") { + repeat(MAX_TRANSIENT_SETUP_FAILURES - 1) { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.ERROR)) + } + runCurrent() + + Then("billing is still treated as transient and the request keeps waiting") { + assertEquals(BillingAvailability.Unknown, wrapper.availability.value) + assertTrue(!job.isCompleted) + } + } + + When("setup fails once more") { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.ERROR)) + runCurrent() + + Then("billing is unavailable and the waiting request fails") { + assertTrue(wrapper.availability.value is BillingAvailability.Unavailable) + assertTrue(job.isCompleted) + assertTrue(job.await().exceptionOrNull() is BillingError.BillingNotAvailable) + } + } + + When("a later reconnect succeeds") { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.OK)) + runCurrent() + + Then("billing is available again") { + assertEquals(BillingAvailability.Available, wrapper.availability.value) + } + } + } + } + + @Test + fun `a successful setup resets the transient failure count`() = + runTest { + Given("a device whose billing setup fails transiently, then connects") { + val wrapper = makeWrapper { disconnectedClient() } + runCurrent() + repeat(MAX_TRANSIENT_SETUP_FAILURES - 1) { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.ERROR)) + } + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.OK)) + runCurrent() + + When("setup later fails transiently again") { + wrapper.onBillingSetupFinished(billingResult(BillingClient.BillingResponseCode.ERROR)) + runCurrent() + + Then("billing is not marked unavailable") { + assertEquals(BillingAvailability.Available, wrapper.availability.value) + } + } + } + } } From 35c2baf4bd4be1cd8a6a2e7eeee13d3910206f84 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 1 Oct 2026 12:54:22 +0000 Subject: [PATCH 07/10] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index ac08ba372..60e048323 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.9% \ No newline at end of file +branches37.7% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index c61b21490..cf878b014 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage48% \ No newline at end of file +coverage46.8% \ No newline at end of file From 55f43e18911ae96cc70634520ac5687daf99a71e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 1 Oct 2026 13:04:47 +0000 Subject: [PATCH 08/10] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index 60e048323..ac08ba372 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches37.7% \ No newline at end of file +branches38.9% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index cf878b014..c61b21490 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage46.8% \ No newline at end of file +coverage48% \ No newline at end of file From aaf4f8137a0c90dfed5c2060b7de7eff1d344ee8 Mon Sep 17 00:00:00 2001 From: Ian Rumac Date: Thu, 1 Oct 2026 15:13:43 +0200 Subject: [PATCH 09/10] Reload a paywall's products after a partial billing-unavailable load A paywall that presented without some Play products because billing was unavailable was cached with failAt unset, so PaywallRequestManager never reloaded its products. Once billing recovered (after the foreground re-probe, or a reconnect following transient setup failures), the paywall still showed without Play prices for the rest of the session. Set failAt for a partial load so the next request retries. Co-Authored-By: Claude Opus 5.5 --- .../com/superwall/sdk/store/StoreManager.kt | 3 ++ .../superwall/sdk/store/StoreManagerTest.kt | 35 +++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt index 94a120550..c6851dd0c 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/StoreManager.kt @@ -224,6 +224,9 @@ class StoreManager( if (productsById.isEmpty() && testMode?.isTestMode != true) throw error val unresolved = processingResult.fullProductIdsToLoad - productsById.keys if (unresolved.isNotEmpty()) { + // Mark the load as failed so a cached paywall reloads its products on the + // next request, picking them up once billing is available again. + paywall.productsLoadingInfo.failAt = Date() Logger.debug( LogLevel.warn, LogScope.productsManager, diff --git a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt index 27f7632b7..de9da04a0 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/StoreManagerTest.kt @@ -847,8 +847,39 @@ class StoreManagerTest { assertEquals(setOf("custom_1"), result.productsByFullId.keys) } - And("the load is not marked as failed") { - assertNull(paywall.productsLoadingInfo.failAt) + And("the load is marked as failed so the paywall reloads its products later") { + junitAssertTrue(paywall.productsLoadingInfo.failAt != null) + } + } + } + } + + @Test + fun `getProducts picks up Play products on reload once billing is available again`() = + runTest { + Given("a mixed paywall that loaded without its Play product while billing was unavailable") { + val manager = storeManagerWith { Either.Success(customProductsResponse("custom_1")) } + manager.fetchAndCacheCustomProducts(setOf("custom_1"), required = true) + val playProduct = + mockk { + every { fullIdentifier } returns "product1:basePlan1:sw-auto" + } + coEvery { billing.awaitGetProducts(any()) } throws + BillingError.BillingNotAvailable("nope") andThen setOf(playProduct) + val paywall = + Paywall.stub().copy( + productIds = listOf("custom_1", "product1:basePlan1:sw-auto"), + ) + manager.getProducts(null, paywall, null) + + When("the paywall's products are loaded again after billing recovers") { + val result = manager.getProducts(null, paywall, null) + + Then("the Play product is included") { + assertEquals( + setOf("custom_1", "product1:basePlan1:sw-auto"), + result.productsByFullId.keys, + ) } } } From d3b029c7a167be27fb997061218d130340d6d0fa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 1 Oct 2026 13:32:17 +0000 Subject: [PATCH 10/10] Update coverage badge [skip ci] --- .github/badges/branches.svg | 2 +- .github/badges/jacoco.svg | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/badges/branches.svg b/.github/badges/branches.svg index ac08ba372..ebe2b57eb 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches38.9% \ No newline at end of file +branches38.8% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index c61b21490..00b798798 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage48% \ No newline at end of file +coverage47.8% \ No newline at end of file