Welcome toVigges Developer Community-Open, Learning,Share
Welcome To Ask or Share your Answers For Others

Categories

0 votes
3.7k views
in Technique[技术] by (71.8m points)

kotlin - purchasesList from BillingClient is null at first

I have the BillingHandler class that you can see below which I'm using to handle in-app billing related logic using google's billing library v.3. I'm using Koin to create a singleton instance using single { BillingHandler(androidContext()) } in my app's module.

Now my issue occurs when I call the class' doesUserOwnPremium() method from my SettingsFragment which uses settings preferences for displaying a preference to be used as a purchase button. Firstly, I use get() to access the billingHandler instance and then call the method to check whether or not the user owns the premium product. I've already purchased it while testing but when I first navigate to the fragment, the purchasesList in the BillingHandler class is null so this returns false. After clicking the preference and attempting to launch a billing flow, the handler's if(!ownsProduct()) {..} logic in loadSKUs() is called and evaluates to false thus notifying me that I do own it.

Both the loadSKUs() method and the doesUserOwnPremium() method call ownsProduct() at different times and return the above results each time. Why is that? Does it have something to do with initialization?

SettingsFragment.kt:

class SettingsFragment : SharedPreferences.OnSharedPreferenceChangeListener
    , PreferenceFragmentCompat() {

    private val TAG = SettingsFragment::class.java.simpleName

    // Billing library setup
    private lateinit var billingHandler:BillingHandler

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        purchasePremiumPreference = findPreference(resources.getString(R.string.purchase_premium))!!
        purchasePremiumPreference.isEnabled = false // disable until the client is ready
        billingHandler = get()

        val ownsPremium = billingHandler.doesUserOwnPremium()
        Toast.makeText(requireContext(),"owns product = $ownsPremium",Toast.LENGTH_LONG).show()
        if(!ownsPremium) {
            purchasePremiumPreference.isEnabled = true
            purchasePremiumPreference.setOnPreferenceClickListener {
                billingHandler.startConnection()
                true
            }
        }
    }
}

BillingHandler.kt:

/**
 * Singleton class that acts as an abstraction layer on top of the Billing library V.3 by google.
 */
class BillingHandler(private val context: Context) : PurchasesUpdatedListener {

    // Billing library setup
    private var billingClient: BillingClient
    private var skuList:ArrayList<String> = ArrayList()
    private val sku = "remove_ads" // the sku to sell
    private lateinit var skuDetails: SkuDetails // the details of the sku to sell
    private var ownsPremium = false

    fun doesUserOwnPremium() : Boolean = ownsPremium

    // analytics
    private lateinit var firebaseAnalytics: FirebaseAnalytics

    init {
        skuList.add(sku) // add SKUs to the sku list (only one in this case)
        billingClient = BillingClient.newBuilder(context)
            .enablePendingPurchases()
            .setListener(this)
            .build()
        ownsPremium = ownsProduct()
    }

    /**
     * Attempts to establish a connection to the billing client. If successful,
     * it will attempt to load the SKUs for sale and begin a billing flow if needed.
     * If the connection fails, it will prompt the user to either retry the connection
     * or cancel it.
     */
    fun startConnection() {
        // start the connection
        billingClient.startConnection(object:BillingClientStateListener {
            override fun onBillingSetupFinished(billingResult: BillingResult) {
                if(billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
                    loadSKUs()
                } else {
                    Toast.makeText(context,"Something went wrong, please try again!",
                        Toast.LENGTH_SHORT).show()
                }
            }

            override fun onBillingServiceDisconnected() {
                Toast.makeText(context,"Billing service disconnected!", Toast.LENGTH_SHORT).show()
                TODO("implement retry policy. Maybe using a dialog w/ retry and cancel buttons")
            }

        })
    }

    /**
     * Loads the skus from the skuList and starts the billing flow
     * for the selected sku(s) if needed.
     */
    private fun loadSKUs() {
        if(billingClient.isReady) { // load the products that the user can purchase
            val skuDetailsParams = SkuDetailsParams.newBuilder()
                .setSkusList(skuList)
                .setType(BillingClient.SkuType.INAPP)
                .build()

            billingClient.querySkuDetailsAsync(skuDetailsParams
            ) { billingResult, skuDetailsList ->
                if(billingResult.responseCode == BillingClient.BillingResponseCode.OK && skuDetailsList != null && skuDetailsList.isNotEmpty()) {
                    // for each sku details object
                    for(skuDetailsObj in skuDetailsList) {
                        // make sure the sku we want to sell is in the list and do something for it
                        if(skuDetailsObj.sku == sku) {
                            if(!ownsProduct()) { // product not owned
                                skuDetails = skuDetailsObj // store the details of that sku
                                startBillingFlow(skuDetailsObj)
                            } else { // give premium benefits
                                Toast.makeText(context,"You already own Premium!",Toast.LENGTH_SHORT).show()
                            }
                        }
                    }
                }
            }
        } else {
            Toast.makeText(context,"Billing client is not ready. Please try again!",Toast.LENGTH_SHORT).show()
        }
    }

    /**
     * Checks whether or not the user owns the desired sku
     * @return True if they own the product, false otherwise
     */
    private fun ownsProduct(): Boolean {
        var ownsProduct = false
        // check if the user already owns this product
        // query the user's purchases (reads them from google play's cache)
        val purchasesResult: Purchase.PurchasesResult =
            billingClient.queryPurchases(BillingClient.SkuType.INAPP)

        val purchasesList = purchasesResult.purchasesList // get the actual list of purchases

        if (purchasesList != null) {
            for (purchase in purchasesList) {
                if (purchase.sku == sku) {
                    ownsProduct = true
                    break
                }
            }
        } else {
            Toast.makeText(context,"Purchases list was null",Toast.LENGTH_SHORT).show()
        }
        return ownsProduct
    }

    /**
     * Starts the billing flow for the purchase of the desired
     * product.
     * @param skuDetailsObj The SkuDetails object of the selected sku
     */
    private fun startBillingFlow(skuDetailsObj:SkuDetails) {
        val billingFlowParams = BillingFlowParams.newBuilder()
            .setSkuDetails(skuDetailsObj)
            .build()

        billingClient.launchBillingFlow(
            context as Activity,
            billingFlowParams
        )
    }

    override fun onPurchasesUpdated(billingResult: BillingResult, purchasesList: MutableList<Purchase>?) {
        if(billingResult.responseCode == BillingClient.BillingResponseCode.OK &&
            purchasesList != null) {
            for(purchase in purchasesList) {
                handlePurchase(purchase)
            }
        }
    }

    /**
     * Handles the given purchase by acknowledging it if needed .
     * @param purchase The purchase to handle
     */
    private fun handlePurchase(purchase: Purchase) {
        // if the user purchased the desired sku
        if(purchase.sku == sku && purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
            if(!purchase.isAcknowledged) { // acknowledge the purchase so that it doesn't get refunded
                val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
                    .setPurchaseToken(purchase.purchaseToken)
                    .build()
                billingClient.acknowledgePurchase(acknowledgePurchaseParams
                ) { billingResult ->
                    if(billingResult.responseCode == BillingClient.BillingResponseCode.OK) { // log the event using firebase
                        // log event to firebase
                        val eventBundle = Bundle()
                        eventBundle.putString(FirebaseAnalytics.Param.ITEM_ID,"purchase_ack")
                        eventBundle.putString(FirebaseAnalytics.Param.ITEM_NAME,"Purchase acknowledged")
                        eventBundle.putString(FirebaseAnalytics.Param.CONTENT_TYPE, "IN_APP_PURCHASES")
                        firebaseAnalytics.logEvent(FirebaseAnalytics.Event.PURCHASE,eventBundle)
                    }
                }
            }
            showPurchaseSuccessDialog()
        }
    }

    /**
     * Shows a dialog to inform the user of the successful purchase
     */
    private fun showPurchaseSuccessDialog() {
        MaterialDialog(context).show {
            title(R.string.premium_success_dialog_title)
            message(R.string.premium_success_dialog_msg)
            icon(R.drawable.ic_premium)
        }
    }
}

与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome To Ask or Share your Answers For Others

1 Answer

0 votes
by (71.8m points)

After doing some more digging and reading the docs again, I realized that when I'm first calling the ownsProduct() method, the billing client's startConnection() method hasn't been called yet thus why the query returned null, the client wasn't ready yet.

I decided to bypass that by simply using the following method to begin a dummy connection in order to set up the client from within my Application class. This way, by the time the user gets anywhere in the app, the client is ready and I can get his/hers actual purchase list.

fun dummyConnection() {
    billingClient.startConnection(object : BillingClientStateListener {
        override fun onBillingSetupFinished(p0: BillingResult) {
        }

        override fun onBillingServiceDisconnected() {
        }

    })
}

I'm guessing that this could have bad side effects so I'd love to get some feedback on whether this is the right way to go about it or not. By the way, I need the client ready as soon as possible because I want to be able to verify that they own premium throughout the app (to disable ads, etc.).


与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…
Welcome to Vigges Developer Community for programmer and developer-Open, Learning and Share
...