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)
}
}
}
与恶龙缠斗过久,自身亦成为恶龙;凝视深渊过久,深渊将回以凝视…