qonversion / android-sdk

Android SDK for cross-platform in-app purchase and subscription infrastructure, revenue analytics, engagement automation, and integrations
123 stars 21 forks source link

QProductCenterManager.fireProductsFailure NullPointerException #643

Closed mihanovak1024 closed 2 weeks ago

mihanovak1024 commented 2 weeks ago

Hey team (@SpertsyanKM ), we just went live with one of our apps that utilises Qonversion for billing.

We're using latest 8.2.1 version.

We noticed the following NullPointerException occuring:

Fatal Exception: java.lang.NullPointerException: Attempt to invoke interface method 'void com.qonversion.android.sdk.listeners.QonversionProductsCallback.onError(com.qonversion.android.sdk.dto.QonversionError)' on a null object reference
       at com.qonversion.android.sdk.internal.QProductCenterManager.fireProductsFailure(:18)
       at com.qonversion.android.sdk.internal.QProductCenterManager.executeProductsBlocks(:26)
       at com.qonversion.android.sdk.internal.QProductCenterManager.loadStoreProductsIfPossible(:27)
       at com.qonversion.android.sdk.internal.QProductCenterManager.access$loadStoreProductsIfPossible()
       at com.qonversion.android.sdk.internal.QProductCenterManager$getLaunchCallback$1.onError(:17)
       at com.qonversion.android.sdk.internal.repository.DefaultRepository$initRequest$1$2.invoke(:41)
       at com.qonversion.android.sdk.internal.repository.DefaultRepository$initRequest$1$2.invoke(:2)
       at com.qonversion.android.sdk.internal.CallBackKt.onFailure(:14)
       at retrofit2.DefaultCallAdapterFactory$ExecutorCallbackCall$1.lambda$onFailure$1$retrofit2-DefaultCallAdapterFactory$ExecutorCallbackCall$1(:2)
       at retrofit2.DefaultCallAdapterFactory$ExecutorCallbackCall$1$$ExternalSyntheticLambda1.run(:6)
       at android.os.Handler.handleCallback(Handler.java:938)
       at android.os.Handler.dispatchMessage(Handler.java:99)
       at android.os.Looper.loop(Looper.java:236)
       at android.app.ActivityThread.main(ActivityThread.java:7912)
       at java.lang.reflect.Method.invoke(Method.java)
       at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:620)
       at com.android.internal.os.ZygoteInit.main(ZygoteInit.java:1011)

and

Fatal Exception: java.lang.NullPointerException: Attempt to invoke interface method 'void com.qonversion.android.sdk.listeners.QonversionProductsCallback.onError(com.qonversion.android.sdk.dto.QonversionError)' on a null object reference
       at com.qonversion.android.sdk.internal.QProductCenterManager.fireProductsFailure(:18)
       at com.qonversion.android.sdk.internal.QProductCenterManager.executeProductsBlocks(:26)
       at com.qonversion.android.sdk.internal.QProductCenterManager.access$executeProductsBlocks()
       at com.qonversion.android.sdk.internal.QProductCenterManager$loadStoreProductsIfPossible$1.invoke(:11)
       at com.qonversion.android.sdk.internal.QProductCenterManager$loadStoreProductsIfPossible$1.invoke(:2)
       at com.qonversion.android.sdk.internal.billing.QonversionBillingService$enrichStoreDataAsync$2.invoke(:28)
       at com.qonversion.android.sdk.internal.billing.QonversionBillingService$enrichStoreDataAsync$2.invoke(:2)
       at com.qonversion.android.sdk.internal.billing.QonversionBillingService.onBillingClientUnavailable$lambda$17$lambda$16$lambda$15(:5)
       at com.qonversion.android.sdk.internal.billing.QonversionBillingService.$r8$lambda$v4F2lifuNmwuzHakTxcTKd0YBYk()
       at com.qonversion.android.sdk.internal.billing.QonversionBillingService$$ExternalSyntheticLambda0.run(:4)

We have the following implementation for Qonversion.shared.products:

suspend fun getProducts(): List<IAPProduct> = suspendCoroutine { cont ->
    Logger.debug(TAG, "getProducts")
    try {
        Qonversion.shared.products(object : QonversionProductsCallback {
            override fun onSuccess(products: Map<String, QProduct>) {
                Logger.debug(TAG, "getProducts - size = ${products.size}")
                val prods = products.values.mapNotNull { it.toIAPProduct() }
                cont.resume(prods)
            }
            override fun onError(error: QonversionError) {
                Logger.warning(TAG, "getProducts - onError = $error")
                triggerEvent("products", error.code.name)
                cont.resume(listOf())
            }
        })
    } catch (e: Exception) {
        Logger.error(TAG, "getProducts", e)
        triggerEvent("products-exception", e.message)
        cont.resume(listOf())
    }
}

I have been digging into the Qonversion open source code and am a bit confused how the QonversionProductsCallback can become null.

Is it possible that our coroutine (when completed) somehow makes everything null, while Qonversion async code is still running? If this is the case, should we just catch this issue with CoroutineExceptionHandler and ignore it since our coroutine probably already completed?

Thanks a lot!

SpertsyanKM commented 2 weeks ago

Hi @mihanovak1024,

Thanks for reaching out! You might be onto something with your assumption — it definitely seems related to GC action. We’ll need to investigate further to identify a proper solution to prevent this exception from occurring.

Could you provide a minimal reproducible example? That would be incredibly helpful. If that’s not possible, any details on how you’re using this method in your coroutine would be greatly appreciated.

mihanovak1024 commented 2 weeks ago

Hey @SpertsyanKM

Could you provide a minimal reproducible example?

I don't have any steps to reproduce, unfortunately. I tried crashing the coroutine in which Qonversion.shared.products is running, but went up with a different (non-qonversion) crash. This proves my assumption above to be wrong. I also tried resuming the coroutine immediately after Qonversion.shared.products call, but I don't get the qonversion crash either.

If that’s not possible, any details on how you’re using this method in your coroutine would be greatly appreciated.

I'm actually doing nothing fancy above this, I'm just running it in a simple IO Coroutine:

  fun updateProducts() {
        CoroutineScope(Dispatchers.IO).launch {
            val transactions = billingProvider.getExistingTransactions()
            withContext(Dispatchers.Main) {
                sendExistingTransactionsToGame(transactions)
            }

            val products = billingProvider.getProducts()
            withContext(Dispatchers.Main) {
                products.forEach { sendPriceInfoToGame(it.priceInfo) }
            }

            val purchases = billingProvider.getPurchases()
            withContext(Dispatchers.Main) {
                purchases.forEach { sendSuccessToGame(it, true) }
            }
        }
    }

Existing transactions (a call prior to getProducts()) are fetched through Entitlements:

suspend fun getExistingTransactions(): List<String> = suspendCoroutine { cont ->
    Logger.debug(TAG, "getExistingTransactions")
    try {
        Qonversion.shared.checkEntitlements(object : QonversionEntitlementsCallback {
            override fun onSuccess(entitlements: Map<String, QEntitlement>) {
                Logger.debug(TAG, "getExistingTransactions - entitlements size = ${entitlements.size}")
                val transactions = entitlements.values.flatMap { it.transactions.map { x -> x.transactionId } }
                cont.resume(transactions)
            }
            override fun onError(error: QonversionError) {
                Logger.warning(TAG, "getExistingTransactions - entitlements onError = $error")
                triggerEvent("existing-transactions", error.code.name)
                cont.resume(listOf())
            }
        })
    } catch (e: Exception) {
        Logger.error(TAG, "getExistingTransactions", e)
        triggerEvent("existing-transactions-exception", e.message)
        cont.resume(listOf())
    }
}

I don't really have anything else I could give you :(

SpertsyanKM commented 2 weeks ago

Thanks for this useful information! Just to clarify, are you experiencing crashes only during the products call, or is it also happening with the checkEntitlements call?

mihanovak1024 commented 2 weeks ago

Sorry I can't be a bit more helpful, but have a really hard time thinking how to reproduce this 😅

Only for products call, even though checkEntitlements always happens before products. products call waits for checkEntitlements to complete before starting.

SpertsyanKM commented 2 weeks ago

Okay, thank you! We'll try to do something with it.

SpertsyanKM commented 2 weeks ago

We’ve just released version 8.2.2, which may resolve this issue. Please upgrade your SDK version.

I’ll close this issue for now, but feel free to reopen it if the problem continues. We’ll also monitor our logs closely to see if it remains a concern.