diff --git a/sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt b/sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt index 6f727db4..ed495699 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 2bacf99a..6067c8cf 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 c228d0c6..0c1f9a9e 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) { + // No-op default. Overridden by existing consumers. + } + + /** + * 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 0535016a..0f6ab4a7 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"