From 10197591ce2dd6b4a124dd5e9dec7cd3d3907e24 Mon Sep 17 00:00:00 2001 From: Raymond Lai Date: Sat, 4 Nov 2023 10:27:35 +0800 Subject: [PATCH] Upgrade Billing library to 6.0.1 Also - some code cleanups at Billing class - move package namespace to build.gradle --- app/build.gradle | 5 +- app/src/main/AndroidManifest.xml | 3 +- .../home_page/ui/options/Billing.kt | 262 +++++++++++------- 3 files changed, 163 insertions(+), 107 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 4c83bd4a..b61da4d4 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -5,7 +5,7 @@ buildscript { glideVersion = '4.11.0' fragment_version = "1.3.6" roomVersion = "2.4.0" - billing_version = "4.1.0" + billing_version = "6.0.1" BASE_CLOUD_FUNC = "https://us-central1-useful-cathode-91310.cloudfunctions.net" BASE_API_STICKER_PACK = "https://us-central1-useful-cathode-91310.cloudfunctions.net/amaze-utils-sticker-pack/" API_REQ_TRIAL_URI = "/amaze-utils-fdroid-trial-validator" @@ -42,6 +42,7 @@ plugins { } android { + namespace "com.amaze.fileutilities" signingConfigs { release } @@ -280,7 +281,7 @@ dependencies { implementation 'org.slf4j:slf4j-api:1.7.32' implementation 'com.github.tony19:logback-android:2.0.0' implementation 'com.stephentuso:welcome:1.4.1' - playImplementation("com.android.billingclient:billing:$billing_version") + playImplementation "com.android.billingclient:billing-ktx:$billing_version" implementation 'me.tankery.lib:circularSeekBar:1.4.0' implementation 'com.github.qoqa:glide-svg:2.0.4' api "com.caverock:androidsvg:1.4" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8b37f673..c43662cd 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,7 +1,6 @@ + xmlns:tools="http://schemas.android.com/tools"> diff --git a/app/src/play/java/com/amaze/fileutilities/home_page/ui/options/Billing.kt b/app/src/play/java/com/amaze/fileutilities/home_page/ui/options/Billing.kt index f4c8b0a2..9d506e9d 100644 --- a/app/src/play/java/com/amaze/fileutilities/home_page/ui/options/Billing.kt +++ b/app/src/play/java/com/amaze/fileutilities/home_page/ui/options/Billing.kt @@ -21,7 +21,6 @@ package com.amaze.fileutilities.home_page.ui.options import android.content.Context -import android.util.Log import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -39,7 +38,24 @@ import com.amaze.fileutilities.utilis.PreferencesConstants import com.amaze.fileutilities.utilis.Utils import com.amaze.fileutilities.utilis.getAppCommonSharedPreferences import com.amaze.fileutilities.utilis.showToastInCenter -import com.android.billingclient.api.* +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.AcknowledgePurchaseResponseListener +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClient.ProductType +import com.android.billingclient.api.BillingClient.ProductType.INAPP +import com.android.billingclient.api.BillingClient.ProductType.SUBS +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingFlowParams.ProductDetailsParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.ConsumeParams +import com.android.billingclient.api.ConsumeResponseListener +import com.android.billingclient.api.ProductDetails +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.QueryProductDetailsParams +import com.android.billingclient.api.QueryProductDetailsParams.Product +import com.android.billingclient.api.QueryPurchasesParams import org.slf4j.Logger import org.slf4j.LoggerFactory import retrofit2.Retrofit @@ -51,25 +67,25 @@ class Billing(val context: Context, private var uniqueId: String) : PurchasesUpdatedListener { constructor(activity: AppCompatActivity, uniqueId: String) : this( activity.baseContext, - uniqueId + uniqueId, ) { this.activity = activity this.uniqueId = uniqueId } - var log: Logger = LoggerFactory.getLogger(Billing::class.java) + private var log: Logger = LoggerFactory.getLogger(Billing::class.java) - private val skuList: MutableList - private val skuListInApp: MutableList - private var skuDetails: ArrayList = ArrayList() + private val inAppSubscriptionList: MutableList + private val inAppProductList: MutableList + private var productDetails: ArrayList = ArrayList() private var fetchedSubs = false private var fetchedInApp = false private var isPurchaseInApp = false - private var activity: AppCompatActivity? = null + private lateinit var activity: AppCompatActivity private var purchaseDialog: AlertDialog? = null // create new donations client - private var billingClient: BillingClient? = null + private val billingClient: BillingClient companion object { private val TAG = Billing::class.java.simpleName @@ -96,12 +112,12 @@ class Billing(val context: Context, private var uniqueId: String) : } init { - skuList = ArrayList() - skuList.add("subscription_1") - skuList.add("subscription_2") - skuListInApp = ArrayList() - skuListInApp.add("lifetime_1") - skuListInApp.add("lifetime_2") + inAppSubscriptionList = ArrayList() + inAppSubscriptionList.add("subscription_1") + inAppSubscriptionList.add("subscription_2") + inAppProductList = ArrayList() + inAppProductList.add("lifetime_1") + inAppProductList.add("lifetime_2") billingClient = BillingClient.newBuilder(context).setListener(this) .enablePendingPurchases().build() @@ -110,6 +126,7 @@ class Billing(val context: Context, private var uniqueId: String) : /** True if billing service is connected now. */ private var isServiceConnected = false private var latestValidPurchase: Purchase? = null + override fun onPurchasesUpdated(response: BillingResult, purchases: List?) { if (response.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { val latestPurchase = handlePurchases(purchases) @@ -122,10 +139,10 @@ class Billing(val context: Context, private var uniqueId: String) : if (!latestPurchase.isAcknowledged) { val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(latestPurchase.purchaseToken) - billingClient!!.acknowledgePurchase( + billingClient.acknowledgePurchase( acknowledgePurchaseParams .build(), - acknowledgePurchaseResponseListener + acknowledgePurchaseResponseListener, ) } } @@ -136,16 +153,17 @@ class Billing(val context: Context, private var uniqueId: String) : val consumeParams = ConsumeParams.newBuilder().setPurchaseToken( latestPurchase - .purchaseToken + .purchaseToken, ).build() - billingClient!!.consumeAsync(consumeParams, purchaseConsumerListener) + billingClient.consumeAsync(consumeParams, purchaseConsumerListener) } } else { log.warn( "failed to acknowledge purchase with response code {} purchases {}", - response.responseCode, purchases?.size + response.responseCode, + purchases?.size, ) - activity?.getString(R.string.operation_failed)?.let { activity?.showToastInCenter(it) } + activity.getString(R.string.operation_failed).let { activity.showToastInCenter(it) } } } @@ -155,12 +173,12 @@ class Billing(val context: Context, private var uniqueId: String) : private val purchaseConsumerListener = ConsumeResponseListener { responseCode1: BillingResult?, - purchaseToken: String? -> + purchaseToken: String?, -> acknowledgePurchase( responseCode1, latestValidPurchase?.purchaseState ?: Purchase.PurchaseState.PURCHASED, - purchaseToken + purchaseToken, ) } @@ -173,7 +191,7 @@ class Billing(val context: Context, private var uniqueId: String) : private fun acknowledgePurchase( responseCode1: BillingResult?, subscriptionStatus: Int, - purchaseToken: String? + purchaseToken: String?, ) { // we consume the purchase, so that user can perform purchase again responseCode1?.responseCode?.let { @@ -189,16 +207,18 @@ class Billing(val context: Context, private var uniqueId: String) : try { service.postValidation( TrialValidationApi.TrialRequest( - TrialValidationApi.AUTH_TOKEN, uniqueId, + TrialValidationApi.AUTH_TOKEN, + uniqueId, context.packageName + "_" + BuildConfig.API_REQ_TRIAL_APP_HASH, subscriptionStatus, - "$purchaseToken@gplay", isPurchaseInApp - ) + "$purchaseToken@gplay", + isPurchaseInApp, + ), )?.execute()?.let { response -> if (response.isSuccessful && response.body() != null) { log.info( "updated subscription state with " + - "response ${response.body()}" + "response ${response.body()}", ) response.body() } @@ -206,10 +226,10 @@ class Billing(val context: Context, private var uniqueId: String) : } catch (e: Exception) { log.warn("failed to update subscription state for trial validation", e) } - if (activity?.isFinishing == false && activity?.isDestroyed == false) { - activity?.runOnUiThread { + if (!activity.isFinishing && !activity.isDestroyed) { + activity.runOnUiThread { purchaseDialog?.dismiss() - Utils.buildSubscriptionPurchasedDialog(activity!!).show() + Utils.buildSubscriptionPurchasedDialog(activity).show() } } else { // do nothing @@ -217,10 +237,11 @@ class Billing(val context: Context, private var uniqueId: String) : } else { log.warn( "failed to acknowledge purchase with response {} token {}", - responseCode1.responseCode, purchaseToken + responseCode1.responseCode, + purchaseToken, ) - activity?.getString(R.string.operation_failed)?.let { - activity?.showToastInCenter(it) + activity.getString(R.string.operation_failed).let { + activity.showToastInCenter(it) } } } @@ -229,13 +250,13 @@ class Billing(val context: Context, private var uniqueId: String) : fun getSubscriptions(resultCallback: () -> Unit) { val runnable = Runnable { log.info("querying for subscriptions") - billingClient?.queryPurchasesAsync( - BillingClient.SkuType.SUBS - ) { p0, subscriptions -> + billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder().setProductType(SUBS).build(), + ) { _, subscriptions -> log.info("found subscriptions {}", subscriptions) - billingClient?.queryPurchasesAsync( - BillingClient.SkuType.INAPP - ) { p0, inApp -> + billingClient.queryPurchasesAsync( + QueryPurchasesParams.newBuilder().setProductType(INAPP).build(), + ) { _, inApp -> log.info("found in app purchases {}", inApp) val purchases = ArrayList() purchases.addAll(subscriptions) @@ -258,55 +279,63 @@ class Billing(val context: Context, private var uniqueId: String) : } } + private fun createProductListFrom(skus: List, @ProductType productType: String): + List { + return skus.map { + Product.newBuilder().setProductId(it).setProductType(productType).build() + } + } + /** Start a purchase flow */ fun initiatePurchaseFlow() { val purchaseFlowRequest = Runnable { - val params = SkuDetailsParams.newBuilder() - params.setSkusList(skuList).setType(BillingClient.SkuType.SUBS) - billingClient!!.querySkuDetailsAsync( - params.build() - ) { responseCode: BillingResult, skuDetailsList: List? -> - if (skuDetailsList != null && skuDetailsList.isNotEmpty()) { + + val params = QueryProductDetailsParams.newBuilder() + params.setProductList(createProductListFrom(inAppSubscriptionList, SUBS)) + billingClient.queryProductDetailsAsync( + params.build(), + ) { responseCode: BillingResult, skuDetailsList: List -> + if (skuDetailsList.isNotEmpty()) { // Successfully fetched product details - skuDetails.addAll(skuDetailsList) + productDetails.addAll(skuDetailsList) fetchedSubs = true popProductsList(responseCode) } else { context.showToastInCenter( context.getString( R.string - .error_fetching_google_play_product_list - ) + .error_fetching_google_play_product_list, + ), ) if (BuildConfig.DEBUG) { log.warn( "Error fetching product list - " + - "looks like you are running a DEBUG build." + "looks like you are running a DEBUG build.", ) } } } - val paramsInApp = SkuDetailsParams.newBuilder() - paramsInApp.setSkusList(skuListInApp).setType(BillingClient.SkuType.INAPP) - billingClient!!.querySkuDetailsAsync( - paramsInApp.build() - ) { responseCode: BillingResult, skuDetailsList: List? -> - if (skuDetailsList != null && skuDetailsList.isNotEmpty()) { + val paramsInApp = QueryProductDetailsParams.newBuilder() + paramsInApp.setProductList(createProductListFrom(inAppProductList, INAPP)) + billingClient.queryProductDetailsAsync( + paramsInApp.build(), + ) { responseCode: BillingResult, skuDetailsList: List -> + if (skuDetailsList.isNotEmpty()) { // Successfully fetched product details - skuDetails.addAll(skuDetailsList) + productDetails.addAll(skuDetailsList) fetchedInApp = true popProductsList(responseCode) } else { context.showToastInCenter( context.getString( R.string - .error_fetching_google_play_product_list - ) + .error_fetching_google_play_product_list, + ), ) if (BuildConfig.DEBUG) { log.warn( "Error fetching product list - " + - "looks like you are running a DEBUG build." + "looks like you are running a DEBUG build.", ) } } @@ -321,8 +350,8 @@ class Billing(val context: Context, private var uniqueId: String) : for (purchase in purchases) { log.info("querying purchase {}", purchase) var containsInApp = false - purchase.skus.forEach { - containsInApp = containsInApp || skuListInApp.contains(it) + purchase.products.forEach { + containsInApp = containsInApp || inAppProductList.contains(it) } if (latestPurchase == null || containsInApp ) { @@ -332,12 +361,14 @@ class Billing(val context: Context, private var uniqueId: String) : if (latestPurchase != null) { log.info("found latest purchase {}", latestPurchase) val existingTrial = dao.findByDeviceId(uniqueId) - latestPurchase.skus.forEach { - isPurchaseInApp = isPurchaseInApp || skuListInApp.contains(it) + latestPurchase.products.forEach { + isPurchaseInApp = isPurchaseInApp || inAppProductList.contains(it) } - val trialStatus = if (isPurchaseInApp) + val trialStatus = if (isPurchaseInApp) { TrialValidationApi.TrialResponse.TRIAL_EXCLUSIVE - else TrialValidationApi.TrialResponse.TRIAL_ACTIVE + } else { + TrialValidationApi.TrialResponse.TRIAL_ACTIVE + } if (existingTrial != null) { existingTrial.subscriptionStatus = latestPurchase.purchaseState existingTrial.purchaseToken = latestPurchase.purchaseToken + "@gplay" @@ -347,7 +378,9 @@ class Billing(val context: Context, private var uniqueId: String) : val trial = Trial( uniqueId, trialStatus, - Trial.TRIAL_DEFAULT_DAYS, Date(), latestPurchase.purchaseState + Trial.TRIAL_DEFAULT_DAYS, + Date(), + latestPurchase.purchaseState, ) trial.purchaseToken = latestPurchase.purchaseToken + "@gplay" dao.insert(trial) @@ -369,11 +402,10 @@ class Billing(val context: Context, private var uniqueId: String) : * Got products list from play store, pop their details * * @param response - * @param skuDetailsList */ private fun popProductsList(response: BillingResult) { if (response.responseCode == BillingClient.BillingResponseCode.OK && - skuDetails.isNotEmpty() && fetchedInApp && fetchedSubs + productDetails.isNotEmpty() && fetchedInApp && fetchedSubs ) { showPaymentsDialog() } @@ -382,33 +414,41 @@ class Billing(val context: Context, private var uniqueId: String) : override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val rootView: View = AdapterDonationBinding.inflate( LayoutInflater.from( - activity + activity, ), - parent, false - ).getRoot() + parent, + false, + ).root return DonationViewHolder(rootView) } override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { - if (holder is DonationViewHolder && skuDetails.size > 0) { - log.info("display sku details {}", skuDetails[position]) - val titleRaw = skuDetails[position].title + if (holder is DonationViewHolder && productDetails.size > 0) { + log.info("display sku details {}", productDetails[position]) + val titleRaw = productDetails[position].title holder.TITLE.text = titleRaw.substring(0, titleRaw.indexOf("(")) - if (skuDetails[position].type == BillingClient.SkuType.INAPP) { + if (productDetails[position].productType == INAPP) { holder.RENEWAL_CYCLE.text = context.getString(R.string.lifetime_membership) + holder.PRICE.text = + productDetails[position].oneTimePurchaseOfferDetails?.formattedPrice } else { var cycle = context.getString(R.string.one_year) - if (!skuDetails[position].subscriptionPeriod.equals("P1Y", true)) { - cycle = skuDetails[position].subscriptionPeriod + // Crude assumption warning + if (!productDetails[position].subscriptionOfferDetails?.first()?.pricingPhases + ?.pricingPhaseList?.first()?.billingPeriod.equals("P1Y", true) + ) { + cycle = productDetails[position].subscriptionOfferDetails?.first() + ?.pricingPhases?.pricingPhaseList?.first()?.billingPeriod.toString() } - holder.RENEWAL_CYCLE.text = context.getString(R.string.renewal_cycle) - .format(cycle) + holder.RENEWAL_CYCLE.text = context.getString(R.string.renewal_cycle).format(cycle) + holder.PRICE.text = productDetails[position].subscriptionOfferDetails?.first() + ?.pricingPhases?.pricingPhaseList?.first()?.formattedPrice } - holder.SUMMARY.text = skuDetails[position].description - holder.PRICE.text = skuDetails[position].price - holder.ROOT_VIEW.setOnClickListener { v -> + holder.SUMMARY.text = productDetails[position].description + + holder.ROOT_VIEW.setOnClickListener { _ -> purchaseProduct.purchaseItem( - skuDetails[position] + productDetails[position], ) purchaseDialog?.dismiss() } @@ -416,21 +456,38 @@ class Billing(val context: Context, private var uniqueId: String) : } override fun getItemCount(): Int { - return skuDetails.size + return productDetails.size } private interface PurchaseProduct { - fun purchaseItem(skuDetails: SkuDetails?) + fun purchaseItem(productDetails: ProductDetails) fun purchaseCancel() } private val purchaseProduct: PurchaseProduct = object : PurchaseProduct { - override fun purchaseItem(skuDetails: SkuDetails?) { - val billingFlowParams = BillingFlowParams.newBuilder().setSkuDetails( - skuDetails!! + override fun purchaseItem(productDetails: ProductDetails) { + // When subscription type, include the offer token too. + // Crude assumption: there is only one offer available in the given subscription + val productDetailsParams = if (productDetails.productType == SUBS && + !productDetails.subscriptionOfferDetails.isNullOrEmpty() && + false == productDetails.subscriptionOfferDetails?.first()?.offerToken?.isEmpty() + ) { + ProductDetailsParams + .newBuilder() + .setProductDetails(productDetails) + .setOfferToken(productDetails.subscriptionOfferDetails!!.first().offerToken) + .build() + } else { + ProductDetailsParams + .newBuilder() + .setProductDetails(productDetails) + .build() + } + val billingFlowParams = BillingFlowParams.newBuilder().setProductDetailsParamsList( + listOf(productDetailsParams) ).build() - activity?.let { - billingClient!!.launchBillingFlow(it, billingFlowParams) + activity.let { + billingClient.launchBillingFlow(it, billingFlowParams) } } @@ -460,13 +517,12 @@ class Billing(val context: Context, private var uniqueId: String) : * @param executeOnSuccess */ private fun startServiceConnection(executeOnSuccess: Runnable?) { - billingClient!!.startConnection( + billingClient.startConnection( object : BillingClientStateListener { override fun onBillingSetupFinished(billingResponse: BillingResult) { - Log.d( - TAG, + log.debug( "Setup finished. Response code: " + - billingResponse.responseCode + billingResponse.responseCode, ) if (billingResponse.responseCode == BillingClient.BillingResponseCode.OK) { isServiceConnected = true @@ -477,13 +533,13 @@ class Billing(val context: Context, private var uniqueId: String) : override fun onBillingServiceDisconnected() { isServiceConnected = false } - }) + } + ) } fun destroyBillingInstance() { - if (billingClient != null && billingClient!!.isReady) { - billingClient!!.endConnection() - billingClient = null + if (billingClient.isReady) { + billingClient.endConnection() } } @@ -494,14 +550,14 @@ class Billing(val context: Context, private var uniqueId: String) : * * */ - if (activity?.isFinishing == false && activity?.isDestroyed == false) { - activity?.runOnUiThread { - val dialogBuilder = AlertDialog.Builder(activity!!, R.style.Custom_Dialog_Dark) + if (!activity.isFinishing && !activity.isDestroyed) { + activity.runOnUiThread { + val dialogBuilder = AlertDialog.Builder(activity, R.style.Custom_Dialog_Dark) .setTitle(R.string.subscribe) .setNegativeButton(R.string.close) { dialog, _ -> dialog.dismiss() } - val inflater = activity!!.layoutInflater + val inflater = activity.layoutInflater val dialogView: View = inflater .inflate(R.layout.subtitles_search_results_view, null) dialogBuilder.setView(dialogView)