Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions sample/src/main/java/io/qonversion/sample/EntitlementsFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -85,11 +86,23 @@ class EntitlementsFragment : Fragment() {

private fun setEntitlementsListener() {
Qonversion.shared.setEntitlementsUpdateListener(object : QEntitlementsUpdateListener {
override fun onEntitlementsUpdated(entitlements: Map<String, QEntitlement>) {
override fun onEntitlementsUpdated(
entitlements: Map<String, QEntitlement>,
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

/**
Expand All @@ -18,5 +19,30 @@ interface QEntitlementsUpdateListener {
*
* @param entitlements all the current entitlements of the user.
*/
fun onEntitlementsUpdated(entitlements: Map<String, QEntitlement>)
@Deprecated(
"Use onEntitlementsUpdated(entitlements, purchaseResult) instead",
ReplaceWith("onEntitlementsUpdated(entitlements, null)")
)
fun onEntitlementsUpdated(entitlements: Map<String, QEntitlement>) {
// 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<String, QEntitlement>,
purchaseResult: QPurchaseResult?
) {
@Suppress("DEPRECATION")
onEntitlementsUpdated(entitlements)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<String, QPurchaseOptions>())

val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false)
val purchases = listOf(purchase)
every {
mockBillingService.queryPurchases(any(), captureLambda())
} answers {
lambda<(List<Purchase>) -> Unit>().captured.invoke(purchases)
}

every { mockBillingService.consumePurchases(any()) } just Runs

val mockListener = mockk<QEntitlementsUpdateListener>(relaxed = true)
every { mockConfig.entitlementsUpdateListener } returns mockListener

val callbackSlot = slot<QonversionLaunchCallback>()
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<String, QonversionPurchaseCallback>()
val mockCallback = mockk<QonversionPurchaseCallback>(relaxed = true)
purchasingCallbacks[productId] = mockCallback
spykProductCenterManager.mockPrivateField("purchasingCallbacks", purchasingCallbacks)
spykProductCenterManager.mockPrivateField("processingPurchaseOptions", emptyMap<String, QPurchaseOptions>())

val purchase = mockPurchase(Purchase.PurchaseState.PURCHASED, false)
val purchases = listOf(purchase)
every {
mockBillingService.queryPurchases(any(), captureLambda())
} answers {
lambda<(List<Purchase>) -> Unit>().captured.invoke(purchases)
}

every { mockBillingService.consumePurchases(any()) } just Runs

val mockListener = mockk<QEntitlementsUpdateListener>(relaxed = true)
every { mockConfig.entitlementsUpdateListener } returns mockListener

val callbackSlot = slot<QonversionLaunchCallback>()
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"

Expand Down