From 8dc6671f8979d54d28f9fa170b702ea532f2c856 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:18:29 +0000 Subject: [PATCH 1/3] Fix purchase hang when purchase controller resolves outside Google Play When a custom PurchaseController completes a purchase on a non-Play store (e.g. Galaxy Store), Google Play Billing never fires onPurchasesUpdated, so getLatestTransaction() suspended forever on the purchaseResults StateFlow. Paywall purchases then never resolved: no transaction_complete, no dismissal, spinner stuck indefinitely. - Bound the wait with a 5s timeout when an external purchase controller is in use; the controller's result is the source of truth and the Play transaction only enriches tracking events, so fall back to null. - Clear the retained purchaseResults value when a purchase starts so a purchase can't be resolved against a stale transaction from an earlier one (StateFlow retains the last emission indefinitely). - Remove a redundant duplicate getLatestTransaction() await in the external purchase path. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YTvfzggPBxHokZmZdauvwo --- .../store/transactions/TransactionManager.kt | 61 +++++++++++-------- .../transactions/TransactionManagerTest.kt | 54 ++++++++++++++++ 2 files changed, 88 insertions(+), 27 deletions(-) diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index 465dea6ba..30f19d13e 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -55,6 +55,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.dropWhile import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import java.util.concurrent.ConcurrentHashMap class TransactionManager( @@ -438,21 +439,14 @@ class TransactionManager( product: StoreProduct? = null, purchaseSource: PurchaseSource, ) { - val purchasingCoordinator = factory.makeTransactionVerifier() - var transaction: StoreTransaction? - val restoreType: RestoreType - - if (product != null) { - // Product exists so much have been via a purchase of a specific product. - transaction = - purchasingCoordinator.getLatestTransaction( - factory = factory, - ) - restoreType = RestoreType.ViaPurchase(transaction) - } else { - // Otherwise it was a generic restore. - restoreType = RestoreType.ViaRestore - } + val restoreType: RestoreType = + if (product != null) { + // Product exists so much have been via a purchase of a specific product. + RestoreType.ViaPurchase(awaitLatestTransaction()) + } else { + // Otherwise it was a generic restore. + RestoreType.ViaRestore + } val trackedEvent = InternalSuperwallEvent.Transaction( @@ -561,6 +555,10 @@ class TransactionManager( val isObserved = source is PurchaseSource.ObserverMode + // The billing flow retains the last Play result indefinitely; clear it so this + // purchase can't be resolved against a stale transaction from an earlier one. + storeManager.billing.purchaseResults.value = null + when (source) { is PurchaseSource.Internal -> { ioScope.launch { @@ -652,11 +650,7 @@ class TransactionManager( ) } - val transactionVerifier = factory.makeTransactionVerifier() - val transaction = - transactionVerifier.getLatestTransaction( - factory = factory, - ) + val transaction = awaitLatestTransaction() storeManager.loadPurchasedProducts(allEntitlementsByProductId()) @@ -682,18 +676,12 @@ class TransactionManager( message = "Transaction Succeeded", info = mapOf("product_id" to product.fullIdentifier), ) - val transactionVerifier = factory.makeTransactionVerifier() val transaction = if (purchaseSource is PurchaseSource.ExternalPurchase) { - transactionVerifier.getLatestTransaction( - factory = factory, - ) if (purchase != null) { factory.makeStoreTransaction(purchase) } else { - transactionVerifier.getLatestTransaction( - factory = factory, - ) + awaitLatestTransaction() } } else { null @@ -995,6 +983,21 @@ class TransactionManager( ) } + // With an external purchase controller the purchase may not go through Google Play + // at all (e.g. Galaxy Store), in which case the billing client never emits a result + // and an unbounded wait would hang the purchase flow forever. The transaction only + // enriches tracking events, so bound the wait and fall back to null. + private suspend fun awaitLatestTransaction(): StoreTransaction? { + val transactionVerifier = factory.makeTransactionVerifier() + return if (factory.makeHasExternalPurchaseController()) { + withTimeoutOrNull(TRANSACTION_LOOKUP_TIMEOUT_MS) { + transactionVerifier.getLatestTransaction(factory) + } + } else { + transactionVerifier.getLatestTransaction(factory) + } + } + private suspend fun trackTransactionDidSucceed( transaction: StoreTransaction?, product: StoreProduct, @@ -1099,4 +1102,8 @@ class TransactionManager( info = info, error = error, ) + + private companion object { + const val TRANSACTION_LOOKUP_TIMEOUT_MS = 5_000L + } } diff --git a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt index 961570a67..5f57ed892 100644 --- a/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt @@ -11,6 +11,7 @@ import com.superwall.sdk.When import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.delegate.InternalPurchaseResult +import com.superwall.sdk.delegate.PurchaseResult import com.superwall.sdk.delegate.RestorationResult import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.misc.ActivityProvider @@ -39,6 +40,7 @@ import io.mockk.mockkObject import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest import org.junit.After @@ -473,6 +475,58 @@ class TransactionManagerTest { // endregion + // region non-Play purchase controller regression (Galaxy Store) + + @Test + fun purchase_internalWithExternalControllerAndNoPlayResult_completesWithoutHanging() = + runTest { + Given("a paywall purchase resolved by an external purchase controller while Google Play never emits a result") { + // Reproduces the Galaxy Store hang: the customer's PurchaseController completes + // the purchase outside Google Play, so the SDK's billing client never fires + // onPurchasesUpdated and getLatestTransaction() used to suspend forever. + val productId = "product1" + val rawProduct = + mockk(relaxed = true) { + every { fullIdentifier } returns productId + every { underlyingProductDetails } returns mockProductDetails(productId) + every { hasFreeTrial } returns false + } + val product = mockStoreProduct(productId, rawProduct) + every { storeManager.getProductFromCache(productId) } returns product + coEvery { + storeManager.purchaseController.purchase(any(), any(), any(), any()) + } returns PurchaseResult.Purchased() + every { factory.makeHasExternalPurchaseController() } returns true + every { factory.makeTransactionVerifier() } returns + mockk { + coEvery { getLatestTransaction(any()) } coAnswers { awaitCancellation() } + } + + When("the purchase is made from a paywall") { + val result = + transactionManager.purchase( + TransactionManager.PurchaseSource.Internal( + productId, + mockk(relaxed = true), + ), + ) + + Then("the purchase resolves and the transaction completes without a Play transaction attached") { + assertTrue(result is PurchaseResult.Purchased) + val completed = + trackedEvents + .filterIsInstance() + .map { it.state } + .filterIsInstance() + assertTrue(completed.isNotEmpty()) + assertTrue(completed.all { it.transaction == null }) + } + } + } + } + + // endregion + private fun mockStoreProduct( productId: String, rawProduct: RawStoreProduct? = null, From 930f445dec692a2866b50f0543f59935eaa64111 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 22 Jul 2026 15:48:51 +0000 Subject: [PATCH 2/3] 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 c904cc451..cf88d0552 100644 --- a/.github/badges/branches.svg +++ b/.github/badges/branches.svg @@ -1 +1 @@ -branches34.9% \ No newline at end of file +branches35.2% \ No newline at end of file diff --git a/.github/badges/jacoco.svg b/.github/badges/jacoco.svg index e1c798d88..b9dcd7843 100644 --- a/.github/badges/jacoco.svg +++ b/.github/badges/jacoco.svg @@ -1 +1 @@ -coverage43.9% \ No newline at end of file +coverage44.7% \ No newline at end of file From e1aa427db6579b5067646428438463eb84415d93 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 15:49:30 +0000 Subject: [PATCH 3/3] Stub purchaseResults on strict Billing mock in instrumentation test prepareToPurchase now clears billing.purchaseResults at purchase start, which the strict mock in the androidTest TransactionManagerTest didn't stub, failing internal purchase tests with MockKException. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01YTvfzggPBxHokZmZdauvwo --- .../superwall/sdk/store/transactions/TransactionManagerTest.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt index 1f3b7665f..cca618f7a 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/store/transactions/TransactionManagerTest.kt @@ -134,6 +134,7 @@ class TransactionManagerTest { ), ) coEvery { queryAllPurchases() } returns emptyList() + every { purchaseResults } returns MutableStateFlow(null) } private var activityProvider = mockk {