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