From 585641b782224401b33273f28210747ae4f1b534 Mon Sep 17 00:00:00 2001 From: Nikita Chechnev Date: Thu, 12 Feb 2026 07:31:50 +0400 Subject: [PATCH 1/2] Add QPurchaseResult to QEntitlementsUpdateListener for deferred purchases When deferred consumable purchases complete in background (no active callback), the SDK now passes QPurchaseResult to the entitlements update listener. This allows developers to access purchase details for consumables that don't create entitlements. Co-Authored-By: Claude Opus 4.6 --- .../qonversion/sample/EntitlementsFragment.kt | 17 +++- .../sdk/internal/QProductCenterManager.kt | 11 ++- .../listeners/QEntitlementsUpdateListener.kt | 28 ++++++- .../sdk/internal/QProductCenterManagerTest.kt | 77 +++++++++++++++++++ 4 files changed, 127 insertions(+), 6 deletions(-) diff --git a/sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt b/sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt index 6f727db4f..ed495699c 100644 --- a/sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt +++ b/sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt @@ -8,6 +8,7 @@ import android.widget.Toast import androidx.fragment.app.Fragment import androidx.recyclerview.widget.LinearLayoutManager import com.qonversion.android.sdk.Qonversion +import com.qonversion.android.sdk.dto.QPurchaseResult import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.entitlements.QEntitlement import com.qonversion.android.sdk.listeners.QEntitlementsUpdateListener @@ -85,11 +86,23 @@ class EntitlementsFragment : Fragment() { private fun setEntitlementsListener() { Qonversion.shared.setEntitlementsUpdateListener(object : QEntitlementsUpdateListener { - override fun onEntitlementsUpdated(entitlements: Map) { + override fun onEntitlementsUpdated( + entitlements: Map, + purchaseResult: QPurchaseResult? + ) { _binding?.let { displayEntitlements(entitlements) } - Toast.makeText(context, getString(R.string.entitlements_updated_via_listener), Toast.LENGTH_SHORT).show() + + if (purchaseResult?.isSuccessful == true && entitlements.isEmpty()) { + Toast.makeText( + context, + "Consumable purchase completed in background", + Toast.LENGTH_SHORT + ).show() + } else { + Toast.makeText(context, getString(R.string.entitlements_updated_via_listener), Toast.LENGTH_SHORT).show() + } } }) isListenerSet = true 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 f9d84af7c..aa1cd9560 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 @@ -563,7 +563,12 @@ internal class QProductCenterManager internal constructor( ) val entitlements = permissions.toEntitlementsMap() - callback?.onResult(QPurchaseResult.successFromFallback(entitlements, purchase)) + if (callback != null) { + callback.onResult(QPurchaseResult.successFromFallback(entitlements, purchase)) + } else { + val purchaseResult = QPurchaseResult.successFromFallback(entitlements, purchase) + internalConfig.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements, purchaseResult) + } } private fun failLocallyGrantingPurchasePermissionsWithError( @@ -1058,8 +1063,8 @@ internal class QProductCenterManager internal constructor( removePurchaseOptions(product?.storeId) if (purchaseCallback == null) { - // If no callback, notify entitlements update listener - internalConfig.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements) + val purchaseResult = QPurchaseResult.success(entitlements, purchase) + internalConfig.entitlementsUpdateListener?.onEntitlementsUpdated(entitlements, purchaseResult) } else { purchaseCallback.onResult(QPurchaseResult.success(entitlements, purchase)) } diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt index c228d0c60..ceaa6e673 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt @@ -1,5 +1,6 @@ package com.qonversion.android.sdk.listeners +import com.qonversion.android.sdk.dto.QPurchaseResult import com.qonversion.android.sdk.dto.entitlements.QEntitlement /** @@ -18,5 +19,30 @@ interface QEntitlementsUpdateListener { * * @param entitlements all the current entitlements of the user. */ - fun onEntitlementsUpdated(entitlements: Map) + @Deprecated( + "Use onEntitlementsUpdated(entitlements, purchaseResult) instead", + ReplaceWith("onEntitlementsUpdated(entitlements, null)") + ) + fun onEntitlementsUpdated(entitlements: Map) { + onEntitlementsUpdated(entitlements, null) + } + + /** + * Called when user entitlements are updated asynchronously. For example when the purchase is made + * with SCA or parental control and thus needs additional confirmation. + * + * For consumable purchases that complete in the background (deferred purchases), + * entitlements may be empty while [purchaseResult] contains the purchase details. + * + * @param entitlements all the current entitlements of the user. + * @param purchaseResult the purchase result associated with this update, if available. + * This is especially useful for consumable purchases that don't create entitlements. + */ + fun onEntitlementsUpdated( + entitlements: Map, + purchaseResult: QPurchaseResult? + ) { + @Suppress("DEPRECATION") + onEntitlementsUpdated(entitlements) + } } 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 e602f63f7..a504ac7e6 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 @@ -7,14 +7,19 @@ import android.os.Build import com.android.billingclient.api.BillingClient import com.android.billingclient.api.Purchase import com.qonversion.android.sdk.dto.QPurchaseOptions +import com.qonversion.android.sdk.dto.QPurchaseResult import com.qonversion.android.sdk.dto.QonversionError import com.qonversion.android.sdk.dto.QonversionErrorCode +import com.qonversion.android.sdk.dto.entitlements.QEntitlement +import com.qonversion.android.sdk.listeners.QEntitlementsUpdateListener import com.qonversion.android.sdk.listeners.QonversionEntitlementsCallback import com.qonversion.android.sdk.listeners.QonversionLaunchCallback +import com.qonversion.android.sdk.listeners.QonversionPurchaseCallback import com.qonversion.android.sdk.internal.api.RequestTrigger import com.qonversion.android.sdk.internal.billing.BillingError import com.qonversion.android.sdk.internal.billing.QonversionBillingService import com.qonversion.android.sdk.internal.dto.QLaunchResult +import com.qonversion.android.sdk.internal.dto.QPermission import com.qonversion.android.sdk.internal.logger.Logger import com.qonversion.android.sdk.internal.provider.AppStateProvider import com.qonversion.android.sdk.internal.repository.QRepository @@ -273,6 +278,78 @@ internal class QProductCenterManagerTest { return purchase } + @Test + fun `deferred purchase with no callback notifies listener with purchaseResult`() { + val spykProductCenterManager = spyk(productCenterManager, recordPrivateCalls = true) + spykProductCenterManager.mockPrivateField("processingPurchaseOptions", emptyMap()) + + val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) + val purchases = listOf(purchase) + every { + mockBillingService.queryPurchases(any(), captureLambda()) + } answers { + lambda<(List) -> Unit>().captured.invoke(purchases) + } + + every { mockBillingService.consumePurchases(any()) } just Runs + + val mockListener = mockk(relaxed = true) + every { mockConfig.entitlementsUpdateListener } returns mockListener + + val callbackSlot = slot() + every { + mockRepository.purchase(any(), any(), any(), any(), capture(callbackSlot)) + } just Runs + + spykProductCenterManager.onAppForeground() + + // Simulate server response with permissions + val launchResult = QLaunchResult("uid", Date(), offerings = null) + callbackSlot.captured.onSuccess(launchResult) + + // Verify listener was called with entitlements AND purchaseResult + verify(exactly = 1) { + mockListener.onEntitlementsUpdated(any(), match { it != null && it.isSuccessful }) + } + } + + @Test + fun `normal purchase with callback does not notify listener`() { + val spykProductCenterManager = spyk(productCenterManager, recordPrivateCalls = true) + val purchasingCallbacks = mutableMapOf() + val mockCallback = mockk(relaxed = true) + purchasingCallbacks[productId] = mockCallback + spykProductCenterManager.mockPrivateField("purchasingCallbacks", purchasingCallbacks) + spykProductCenterManager.mockPrivateField("processingPurchaseOptions", emptyMap()) + + val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false) + val purchases = listOf(purchase) + every { + mockBillingService.queryPurchases(any(), captureLambda()) + } answers { + lambda<(List) -> Unit>().captured.invoke(purchases) + } + + every { mockBillingService.consumePurchases(any()) } just Runs + + val mockListener = mockk(relaxed = true) + every { mockConfig.entitlementsUpdateListener } returns mockListener + + val callbackSlot = slot() + every { + mockRepository.purchase(any(), any(), any(), any(), capture(callbackSlot)) + } just Runs + + spykProductCenterManager.onAppForeground() + + val launchResult = QLaunchResult("uid", Date(), offerings = null) + callbackSlot.captured.onSuccess(launchResult) + + // Verify callback received result but listener was NOT called + verify(exactly = 1) { mockCallback.onResult(any()) } + verify(exactly = 0) { mockListener.onEntitlementsUpdated(any(), any()) } + } + private fun mockInstallDate() { val packageName = "packageName" From ab24e6554fb785ef3d348d728bed7326c3699fc4 Mon Sep 17 00:00:00 2001 From: NickSxti Date: Thu, 19 Feb 2026 15:48:36 +0400 Subject: [PATCH 2/2] Fix mutual recursion in QEntitlementsUpdateListener default methods Replace the 1-arg method's default body (which delegated to 2-arg) with a no-op. This breaks the mutual recursion cycle that would cause StackOverflowError if neither method was overridden. Co-Authored-By: Claude Opus 4.6 --- .../android/sdk/listeners/QEntitlementsUpdateListener.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt index ceaa6e673..0c1f9a9e7 100644 --- a/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt +++ b/sdk/src/main/java/com/qonversion/android/sdk/listeners/QEntitlementsUpdateListener.kt @@ -24,7 +24,7 @@ interface QEntitlementsUpdateListener { ReplaceWith("onEntitlementsUpdated(entitlements, null)") ) fun onEntitlementsUpdated(entitlements: Map) { - onEntitlementsUpdated(entitlements, null) + // No-op default. Overridden by existing consumers. } /**