Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/badges/branches.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion .github/badges/jacoco.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ class TransactionManagerTest {
),
)
coEvery { queryAllPurchases() } returns emptyList()
every { purchaseResults } returns MutableStateFlow(null)
}
private var activityProvider =
mockk<ActivityProvider> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -652,11 +650,7 @@ class TransactionManager(
)
}

val transactionVerifier = factory.makeTransactionVerifier()
val transaction =
transactionVerifier.getLatestTransaction(
factory = factory,
)
val transaction = awaitLatestTransaction()

storeManager.loadPurchasedProducts(allEntitlementsByProductId())

Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1099,4 +1102,8 @@ class TransactionManager(
info = info,
error = error,
)

private companion object {
const val TRANSACTION_LOOKUP_TIMEOUT_MS = 5_000L
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<RawStoreProduct>(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<InternalSuperwallEvent.Transaction>()
.map { it.state }
.filterIsInstance<InternalSuperwallEvent.Transaction.State.Complete>()
assertTrue(completed.isNotEmpty())
assertTrue(completed.all { it.transaction == null })
}
}
}
}

// endregion

private fun mockStoreProduct(
productId: String,
rawProduct: RawStoreProduct? = null,
Expand Down
Loading