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"