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 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,