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 @@
-
\ No newline at end of file
+
\ 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 {