diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index 302e4d57d..00b798798 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage47.7% \ No newline at end of file +coverage47.8% \ No newline at end of file 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 new file mode 100644 index 000000000..05ff1c7e0 --- /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. + */ +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 +} 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..2c509efa1 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 @@ -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, @@ -101,6 +107,17 @@ class GoogleBillingWrapper( @set:Synchronized private var reconnectionAlreadyScheduled = false + private val transientSetupFailures = AtomicInteger(0) + + 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. + */ + internal val availability = _availability.asStateFlow() + // Setup mutable state flow for purchase results override val purchaseResults = MutableStateFlow(null) @@ -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( @@ -148,14 +179,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 +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 @@ -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) } }, @@ -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() @@ -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() } @@ -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, @@ -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() } @@ -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) { 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..c6851dd0c 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,26 @@ 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 + 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, + "Google Play Billing is not available, presenting the paywall without " + + "products: ${unresolved.joinToString()}", + ) + } } } catch (error: Throwable) { paywall.productsLoadingInfo.failAt = Date() @@ -223,8 +246,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 +260,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 +271,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 +316,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 +325,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/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..213a80903 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/store/testmode/ui/BottomSheetDialogExt.kt @@ -0,0 +1,18 @@ +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. 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) + ?.doOnLayout { it.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) 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..6203192d6 --- /dev/null +++ b/superwall/src/test/java/com/superwall/sdk/billing/GoogleBillingWrapperAvailabilityTest.kt @@ -0,0 +1,316 @@ +@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()) } + } + } + } + } + + @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) + } + } + } + } +} 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..de9da04a0 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,90 @@ 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 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, + ) + } + } + } + } + + @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( 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 {