From 22a49673e7d02866dc05845c05e05e0fb9fb67dc 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 2bacf99a5..6067c8cfc 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 @@ -544,7 +544,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( @@ -1039,8 +1044,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 0535016aa..0f6ab4a75 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,10 +7,15 @@ 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.entitlements.QEntitlement +import com.qonversion.android.sdk.listeners.QEntitlementsUpdateListener import com.qonversion.android.sdk.listeners.QonversionLaunchCallback +import com.qonversion.android.sdk.listeners.QonversionPurchaseCallback 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 @@ -168,6 +173,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 745db80d4efabcd15fef7585be5cbc5d434ddd83 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. } /**