From 05fca410c7c88c28da51bcc7f999f810622f05f6 Mon Sep 17 00:00:00 2001 From: NickSxti Date: Thu, 9 Apr 2026 19:13:28 +0400 Subject: [PATCH] fix: SUP3-118 defer consumePurchases when product types unknown When restore() or handlePurchases() runs before launch() completes (e.g., app calls restore immediately after SDK init on fresh install), getNonConsumableStoreIds() returns emptySet() because the product cache is not yet populated. This causes ALL in-app purchases to be consumed, permanently destroying Lifetime purchases. Fix: getNonConsumableStoreIds() now returns null (not emptySet()) when products are unavailable. restore() and handlePurchases() use a hybrid approach - consume immediately if products are known (preserving current behavior), or defer to onSuccess callback where fresh product types from the API response are available. If the API call fails and products are still unknown, no consume happens - the purchase stays safe in Google Play and will be processed on the next successful launch. The launch onSuccess path (line 807) already followed this pattern correctly (updateLaunchResult before consumePurchases). Co-Authored-By: Claude Opus 4.6 (1M context) --- .../sdk/internal/QProductCenterManager.kt | 31 ++++- .../sdk/internal/QProductCenterManagerTest.kt | 115 ++++++++++++++++++ 2 files changed, 141 insertions(+), 5 deletions(-) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt index d418389c..9f717616 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/internal/QProductCenterManager.kt @@ -416,7 +416,10 @@ internal class QProductCenterManager internal constructor( return@queryPurchases } - billingService.consumePurchases(purchases, getNonConsumableStoreIds()) + val nonConsumableIds = getNonConsumableStoreIds() + if (nonConsumableIds != null) { + billingService.consumePurchases(purchases, nonConsumableIds) + } val purchaseRecords = purchases.map { PurchaseRecord(it) } repository.restore( @@ -427,6 +430,14 @@ internal class QProductCenterManager internal constructor( override fun onSuccess(launchResult: QLaunchResult) { handleUserSwitchingOnRestore(launchResult) updateLaunchResult(launchResult) + + if (nonConsumableIds == null) { + billingService.consumePurchases( + purchases, + getNonConsumableStoreIds() ?: emptySet() + ) + } + executeRestoreBlocksOnSuccess(launchResult.permissions.toEntitlementsMap()) } @@ -793,7 +804,7 @@ internal class QProductCenterManager internal constructor( if (processingPurchases.isNotEmpty()) { handledPurchasesCache.saveHandledPurchases(processingPurchases) - billingService.consumePurchases(processingPurchases.toList(), getNonConsumableStoreIds()) + billingService.consumePurchases(processingPurchases.toList(), getNonConsumableStoreIds() ?: emptySet()) processingPurchases = emptyList() } @@ -1029,7 +1040,10 @@ internal class QProductCenterManager internal constructor( } private fun handlePurchases(purchases: List, requestTrigger: RequestTrigger) { - billingService.consumePurchases(purchases, getNonConsumableStoreIds()) + val nonConsumableIds = getNonConsumableStoreIds() + if (nonConsumableIds != null) { + billingService.consumePurchases(purchases, nonConsumableIds) + } purchases.forEach { purchase -> val purchaseCallback = purchasingCallbacks[purchase.productId] @@ -1063,6 +1077,13 @@ internal class QProductCenterManager internal constructor( override fun onSuccess(launchResult: QLaunchResult) { updateLaunchResult(launchResult) + if (nonConsumableIds == null) { + billingService.consumePurchases( + listOf(purchase), + getNonConsumableStoreIds() ?: emptySet() + ) + } + val entitlements = launchResult.permissions.toEntitlementsMap() removePurchaseOptions(product?.storeId) @@ -1126,11 +1147,11 @@ internal class QProductCenterManager internal constructor( ) } - private fun getNonConsumableStoreIds(): Set { + private fun getNonConsumableStoreIds(): Set? { return launchResultCache.getActualProducts()?.values ?.filter { it.isNonConsumable } ?.mapNotNull { it.storeId } - ?.toSet() ?: emptySet() + ?.toSet() } private inline fun forEachPurchaseCallback(purchases: List, action: (QonversionPurchaseCallback) -> Unit) { diff --git a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt index e602f63f..f7fd4d21 100644 --- a/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt +++ b/sdk/src/test/java/com/qonversion/android/sdk/internal/QProductCenterManagerTest.kt @@ -225,6 +225,121 @@ internal class QProductCenterManagerTest { verify { callback.onError(any()) } } + // SUP3-118: Lifetime purchases consumed when restore() races with launch() + + @Test + fun `restore should not call consumePurchases before API when products not loaded`() { + // When products are not loaded (fresh install, launch not complete), + // consumePurchases should NOT be called before the restore API call. + // It should only be called in onSuccess after products arrive from the response. + every { mockLaunchResultCacheWrapper.getActualProducts() } returns null + + val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) + every { mockBillingService.queryPurchases(any(), captureLambda()) } answers { + lambda<(List) -> Unit>().captured.invoke(listOf(purchase)) + } + every { mockBillingService.consumePurchases(any(), any()) } just Runs + + val callbackSlot = slot() + every { + mockRepository.restore(any(), any(), any(), capture(callbackSlot)) + } just Runs + + val callback = mockk(relaxed = true) + productCenterManager.restore(RequestTrigger.Restore, callback) + + // consumePurchases should NOT have been called yet (before API response) + verify(exactly = 0) { mockBillingService.consumePurchases(any(), any()) } + + // Now simulate API success - products become available + val launchResult = QLaunchResult("uid", Date(), offerings = null) + every { mockLaunchResultCacheWrapper.getActualProducts() } returns emptyMap() + callbackSlot.captured.onSuccess(launchResult) + + // NOW consumePurchases should be called with the fresh data + verify(exactly = 1) { mockBillingService.consumePurchases(any(), any()) } + } + + @Test + fun `restore should call consumePurchases immediately when products are loaded`() { + // When products are already loaded (launch completed), current behavior is preserved: + // consumePurchases is called immediately before the API call. + every { mockLaunchResultCacheWrapper.getActualProducts() } returns emptyMap() + + val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) + every { mockBillingService.queryPurchases(any(), captureLambda()) } answers { + lambda<(List) -> Unit>().captured.invoke(listOf(purchase)) + } + every { mockBillingService.consumePurchases(any(), any()) } just Runs + + every { + mockRepository.restore(any(), any(), any(), any()) + } just Runs + + val callback = mockk(relaxed = true) + productCenterManager.restore(RequestTrigger.Restore, callback) + + // consumePurchases should be called immediately (before API response) + verify(exactly = 1) { mockBillingService.consumePurchases(any(), any()) } + } + + @Test + fun `restore should not call consumePurchases on API error when products not loaded`() { + every { mockLaunchResultCacheWrapper.getActualProducts() } returns null + + val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) + every { mockBillingService.queryPurchases(any(), captureLambda()) } answers { + lambda<(List) -> Unit>().captured.invoke(listOf(purchase)) + } + every { mockBillingService.consumePurchases(any(), any()) } just Runs + + val callbackSlot = slot() + every { + mockRepository.restore(any(), any(), any(), capture(callbackSlot)) + } just Runs + + val callback = mockk(relaxed = true) + productCenterManager.restore(RequestTrigger.Restore, callback) + + // Simulate API error + callbackSlot.captured.onError(QonversionError(QonversionErrorCode.BackendError)) + + // consumePurchases should NEVER be called - purchase stays safe in Google + verify(exactly = 0) { mockBillingService.consumePurchases(any(), any()) } + } + + @Test + fun `handlePurchases should not call consumePurchases before API when products not loaded`() { + val spykProductCenterManager = spyk(productCenterManager, recordPrivateCalls = true) + spykProductCenterManager.mockPrivateField("processingPurchaseOptions", emptyMap()) + + every { mockLaunchResultCacheWrapper.getActualProducts() } returns null + + val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) + every { mockBillingService.queryPurchases(any(), captureLambda()) } answers { + lambda<(List) -> Unit>().captured.invoke(listOf(purchase)) + } + every { mockBillingService.consumePurchases(any(), any()) } just Runs + + val callbackSlot = slot() + every { + mockRepository.purchase(any(), any(), any(), any(), capture(callbackSlot)) + } just Runs + + spykProductCenterManager.onAppForeground() + + // consumePurchases should NOT be called before API response + verify(exactly = 0) { mockBillingService.consumePurchases(any(), any()) } + + // Simulate purchase API success - products become available + val launchResult = QLaunchResult("uid", Date(), offerings = null) + every { mockLaunchResultCacheWrapper.getActualProducts() } returns emptyMap() + callbackSlot.captured.onSuccess(launchResult) + + // NOW consumePurchases should be called + verify(exactly = 1) { mockBillingService.consumePurchases(any(), any()) } + } + @Test fun `restore with empty uid in response should not trigger user switch`() { val currentUid = "user_current"