From 5a17dec799f95fd7fc551ec826365ca9fb12fb35 Mon Sep 17 00:00:00 2001 From: sictiru Date: Mon, 27 Dec 2021 22:35:31 -0800 Subject: [PATCH] Subscription manager to handle billing and subscription syncs between Play Store and NewsBlur. --- .../src/com/newsblur/activity/Premium.kt | 227 +++------------ .../newsblur/fragment/ItemSetFragment.java | 1 + .../subscription/SubscriptionManager.kt | 264 ++++++++++++++++++ 3 files changed, 304 insertions(+), 188 deletions(-) create mode 100644 clients/android/NewsBlur/src/com/newsblur/subscription/SubscriptionManager.kt diff --git a/clients/android/NewsBlur/src/com/newsblur/activity/Premium.kt b/clients/android/NewsBlur/src/com/newsblur/activity/Premium.kt index f270838f0..d2664482e 100644 --- a/clients/android/NewsBlur/src/com/newsblur/activity/Premium.kt +++ b/clients/android/NewsBlur/src/com/newsblur/activity/Premium.kt @@ -4,90 +4,43 @@ import android.graphics.Color import android.graphics.Paint import android.net.Uri import android.os.Bundle -import android.text.TextUtils import android.text.util.Linkify import android.view.View import android.widget.TextView -import androidx.lifecycle.lifecycleScope import com.android.billingclient.api.* import com.newsblur.R import com.newsblur.databinding.ActivityPremiumBinding -import com.newsblur.network.APIManager -import com.newsblur.service.NBSyncService +import com.newsblur.subscription.SubscriptionManager +import com.newsblur.subscription.SubscriptionManagerImpl +import com.newsblur.subscription.SubscriptionsListener import com.newsblur.util.* import nl.dionsegijn.konfetti.emitters.StreamEmitter import nl.dionsegijn.konfetti.models.Shape.Circle import nl.dionsegijn.konfetti.models.Shape.Square import nl.dionsegijn.konfetti.models.Size -import java.text.DateFormat -import java.text.SimpleDateFormat import java.util.* class Premium : NbActivity() { private lateinit var binding: ActivityPremiumBinding - private lateinit var billingClient: BillingClient + private lateinit var subscriptionManager: SubscriptionManager - private var subscriptionDetails: SkuDetails? = null - private var purchasedSubscription: Purchase? = null + private val subscriptionManagerListener = object : SubscriptionsListener { - private val acknowledgePurchaseResponseListener = AcknowledgePurchaseResponseListener { billingResult: BillingResult -> - when (billingResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - Log.d(this@Premium.localClassName, "acknowledgePurchaseResponseListener OK") - verifyUserSubscriptionStatus() - } - BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> { - // Billing API version is not supported for the type requested. - Log.d(this@Premium.localClassName, "acknowledgePurchaseResponseListener BILLING_UNAVAILABLE") - } - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> { - // Network connection is down. - Log.d(this@Premium.localClassName, "acknowledgePurchaseResponseListener SERVICE_UNAVAILABLE") - } - else -> { - // Handle any other error codes. - Log.d(this@Premium.localClassName, "acknowledgePurchaseResponseListener ERROR - message: " + billingResult.debugMessage) - } - } - } - private val purchaseUpdateListener = PurchasesUpdatedListener { billingResult: BillingResult, purchases: List? -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { - Log.d(this@Premium.localClassName, "purchaseUpdateListener OK") - for (purchase in purchases) { - handlePurchase(purchase) - } - } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { - // Handle an error caused by a user cancelling the purchase flow. - Log.d(this@Premium.localClassName, "purchaseUpdateListener USER_CANCELLED") - } else if (billingResult.responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) { - // Billing API version is not supported for the type requested. - Log.d(this@Premium.localClassName, "purchaseUpdateListener BILLING_UNAVAILABLE") - } else if (billingResult.responseCode == BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) { - // Network connection is down. - Log.d(this@Premium.localClassName, "purchaseUpdateListener SERVICE_UNAVAILABLE") - } else { - // Handle any other error codes. - Log.d(this@Premium.localClassName, "purchaseUpdateListener ERROR - message: " + billingResult.debugMessage) - } - } - private val billingClientStateListener: BillingClientStateListener = object : BillingClientStateListener { - override fun onBillingSetupFinished(billingResult: BillingResult) { - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - // The BillingClient is ready. You can query purchases here. - Log.d(this@Premium.localClassName, "onBillingSetupFinished OK") - retrievePlayStoreSubscriptions() - verifyUserSubscriptionStatus() - } else { - showSubscriptionDetailsError() - } + override fun onActiveSubscription(renewalMessage: String?) { + showActiveSubscriptionDetails(renewalMessage) } - override fun onBillingServiceDisconnected() { - Log.d(this@Premium.localClassName, "onBillingServiceDisconnected") - // Try to restart the connection on the next request to - // Google Play by calling the startConnection() method. - showSubscriptionDetailsError() + override fun onAvailableSubscription(skuDetails: SkuDetails) { + showAvailableSubscriptionDetails(skuDetails) + } + + override fun onBillingConnectionReady() { + subscriptionManager.getSubscriptionState() + } + + override fun onBillingConnectionError(message: String?) { + showSubscriptionDetailsError(message) } } @@ -96,7 +49,7 @@ class Premium : NbActivity() { binding = ActivityPremiumBinding.inflate(layoutInflater) setContentView(binding.root) setupUI() - setupBillingClient() + setupBilling() } private fun setupUI() { @@ -113,129 +66,45 @@ class Premium : NbActivity() { FeedUtils.iconLoader!!.displayImage(AppConstants.SHILOH_PHOTO_URL, binding.imgShiloh) } - private fun setupBillingClient() { - billingClient = BillingClient.newBuilder(this) - .setListener(purchaseUpdateListener) - .enablePendingPurchases() - .build() - billingClient.startConnection(billingClientStateListener) + private fun setupBilling() { + subscriptionManager = SubscriptionManagerImpl(this, subscriptionManagerListener) + subscriptionManager.startBillingConnection() } - private fun verifyUserSubscriptionStatus() { - val hasNewsBlurSubscription = PrefsUtils.getIsPremium(this) - var playStoreSubscription: Purchase? = null - val result = billingClient.queryPurchases(BillingClient.SkuType.SUBS) - if (result.purchasesList != null) { - for (purchase in result.purchasesList!!) { - if (purchase.sku == AppConstants.PREMIUM_SKU) { - playStoreSubscription = purchase - } - } - } - if (hasNewsBlurSubscription || playStoreSubscription != null) { - binding.containerGoingPremium.visibility = View.GONE - binding.containerGonePremium.visibility = View.VISIBLE - val expirationTimeMs = PrefsUtils.getPremiumExpire(this) - var renewalString: String? = null - if (expirationTimeMs == 0L) { - renewalString = getString(R.string.premium_subscription_no_expiration) - } else if (expirationTimeMs > 0) { - // date constructor expects ms - val expirationDate = Date(expirationTimeMs * 1000) - val dateFormat: DateFormat = SimpleDateFormat("EEE, MMMM d, yyyy", Locale.getDefault()) - dateFormat.timeZone = TimeZone.getDefault() - renewalString = getString(R.string.premium_subscription_renewal, dateFormat.format(expirationDate)) - if (playStoreSubscription != null && !playStoreSubscription.isAutoRenewing) { - renewalString = getString(R.string.premium_subscription_expiration, dateFormat.format(expirationDate)) - } - } - if (!TextUtils.isEmpty(renewalString)) { - binding.textSubscriptionRenewal.text = renewalString - binding.textSubscriptionRenewal.visibility = View.VISIBLE - } - showConfetti() - } - if (!hasNewsBlurSubscription && playStoreSubscription != null) { - purchasedSubscription = playStoreSubscription - notifyNewsBlurOfSubscription() - } - } - - private fun retrievePlayStoreSubscriptions() { - val skuList: MutableList = ArrayList(1) - // add sub SKUs from Play Store - skuList.add(AppConstants.PREMIUM_SKU) - val params = SkuDetailsParams.newBuilder() - params.setSkusList(skuList).setType(BillingClient.SkuType.SUBS) - billingClient.querySkuDetailsAsync(params.build()) { _: BillingResult?, skuDetailsList: List? -> - Log.d(this@Premium.localClassName, "SkuDetailsResponse") - processSkuDetailsList(skuDetailsList) - } - } - - private fun processSkuDetailsList(skuDetailsList: List?) { - if (skuDetailsList != null) { - for (skuDetails in skuDetailsList) { - if (skuDetails.sku == AppConstants.PREMIUM_SKU) { - Log.d(this@Premium.localClassName, "Sku detail: " + skuDetails.title + " | " + skuDetails.description + " | " + skuDetails.price + " | " + skuDetails.sku) - subscriptionDetails = skuDetails - } - } - } - if (subscriptionDetails != null) { - showSubscriptionDetails() - } else { - showSubscriptionDetailsError() - } - } - - private fun showSubscriptionDetailsError() { - binding.textLoading.setText(R.string.premium_subscription_details_error) + private fun showSubscriptionDetailsError(message: String?) { + binding.textLoading.text = message ?: getString(R.string.premium_subscription_details_error) binding.textLoading.visibility = View.VISIBLE binding.containerSub.visibility = View.GONE } - private fun showSubscriptionDetails() { - val price = (subscriptionDetails!!.priceAmountMicros / 1000f / 1000f).toDouble() - val currency = Currency.getInstance(subscriptionDetails!!.priceCurrencyCode) + private fun showAvailableSubscriptionDetails(skuDetails: SkuDetails) { + val price = (skuDetails.priceAmountMicros / 1000f / 1000f).toDouble() + val currency = Currency.getInstance(skuDetails.priceCurrencyCode) val currencySymbol = currency.getSymbol(Locale.getDefault()) val pricingText = StringBuilder() - pricingText.append(subscriptionDetails!!.price) + pricingText.append(skuDetails.price) pricingText.append(" per year (") pricingText.append(currencySymbol) pricingText.append(String.format(Locale.getDefault(), "%.2f", price / 12)) pricingText.append("/month)") - binding.textSubTitle.text = subscriptionDetails!!.title + binding.textSubTitle.text = skuDetails.title binding.textSubPrice.text = pricingText binding.textLoading.visibility = View.GONE binding.containerSub.visibility = View.VISIBLE - binding.containerSub.setOnClickListener { launchBillingFlow(subscriptionDetails!!) } - } - - private fun launchBillingFlow(skuDetails: SkuDetails) { - Log.d(this@Premium.localClassName, "launchBillingFlow for sku: " + skuDetails.sku) - val billingFlowParams = BillingFlowParams.newBuilder() - .setSkuDetails(skuDetails) - .build() - billingClient.launchBillingFlow(this, billingFlowParams) - } - - private fun handlePurchase(purchase: Purchase) { - Log.d(this@Premium.localClassName, "handlePurchase: " + purchase.orderId) - purchasedSubscription = purchase - if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && purchase.isAcknowledged) { - verifyUserSubscriptionStatus() - } else if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) { - // need to acknowledge first time sub otherwise it will void - Log.d(this@Premium.localClassName, "acknowledge purchase: " + purchase.orderId) - val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder() - .setPurchaseToken(purchase.purchaseToken) - .build() - billingClient.acknowledgePurchase(acknowledgePurchaseParams, acknowledgePurchaseResponseListener) + binding.containerSub.setOnClickListener { + subscriptionManager.purchaseSubscription(this, skuDetails) } } - private fun showConfetti() { + private fun showActiveSubscriptionDetails(renewalMessage: String?) { + binding.containerGoingPremium.visibility = View.GONE + binding.containerGonePremium.visibility = View.VISIBLE + + if (!renewalMessage.isNullOrEmpty()) { + binding.textSubscriptionRenewal.text = renewalMessage + binding.textSubscriptionRenewal.visibility = View.VISIBLE + } + binding.konfetti.build() .addColors(Color.YELLOW, Color.GREEN, Color.MAGENTA, Color.BLUE, Color.CYAN, Color.RED) .setDirection(90.0) @@ -246,22 +115,4 @@ class Premium : NbActivity() { .setPosition(0f, binding.konfetti.width + 0f, -50f, -20f) .streamFor(100, StreamEmitter.INDEFINITE) } - - private fun notifyNewsBlurOfSubscription() { - if (purchasedSubscription != null) { - val apiManager = APIManager(this) - lifecycleScope.executeAsyncTask( - doInBackground = { - apiManager.saveReceipt(purchasedSubscription!!.orderId, purchasedSubscription!!.sku) - }, - onPostExecute = { - if (!it.isError) { - NBSyncService.forceFeedsFolders() - triggerSync() - } - finish() - } - ) - } - } } \ No newline at end of file diff --git a/clients/android/NewsBlur/src/com/newsblur/fragment/ItemSetFragment.java b/clients/android/NewsBlur/src/com/newsblur/fragment/ItemSetFragment.java index 358bad71f..6b59477fb 100644 --- a/clients/android/NewsBlur/src/com/newsblur/fragment/ItemSetFragment.java +++ b/clients/android/NewsBlur/src/com/newsblur/fragment/ItemSetFragment.java @@ -8,6 +8,7 @@ import android.os.Parcelable; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.core.content.ContextCompat; import androidx.lifecycle.ViewModelProvider; import androidx.recyclerview.widget.GridLayoutManager; import androidx.recyclerview.widget.RecyclerView; diff --git a/clients/android/NewsBlur/src/com/newsblur/subscription/SubscriptionManager.kt b/clients/android/NewsBlur/src/com/newsblur/subscription/SubscriptionManager.kt new file mode 100644 index 000000000..d5abcc722 --- /dev/null +++ b/clients/android/NewsBlur/src/com/newsblur/subscription/SubscriptionManager.kt @@ -0,0 +1,264 @@ +package com.newsblur.subscription + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.AcknowledgePurchaseParams +import com.android.billingclient.api.AcknowledgePurchaseResponseListener +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingClientStateListener +import com.android.billingclient.api.BillingFlowParams +import com.android.billingclient.api.BillingResult +import com.android.billingclient.api.Purchase +import com.android.billingclient.api.PurchasesUpdatedListener +import com.android.billingclient.api.SkuDetails +import com.android.billingclient.api.SkuDetailsParams +import com.newsblur.R +import com.newsblur.network.APIManager +import com.newsblur.service.NBSyncService +import com.newsblur.util.AppConstants +import com.newsblur.util.FeedUtils +import com.newsblur.util.Log +import com.newsblur.util.NBScope +import com.newsblur.util.PrefsUtils +import com.newsblur.util.executeAsyncTask +import java.text.DateFormat +import java.text.SimpleDateFormat +import java.util.* + +interface SubscriptionManager { + + /** + * Open connection to Play Store to retrieve + * purchases and subscriptions. + */ + fun startBillingConnection() + + /** + * Generated subscription state by retrieve all available subscriptions + * and checking whether the user has an active subscription. + * + * Subscriptions are configured via the Play Store console. + */ + fun getSubscriptionState() + + /** + * Launch the billing flow overlay for a specific subscription. + * @param activity Activity on which the billing overlay will be displayed. + * @param skuDetails Subscription details for the intended purchases. + */ + fun purchaseSubscription(activity: Activity, skuDetails: SkuDetails) + + /** + * Sync subscription state between NewsBlur and Play Store. + */ + fun syncActiveSubscription() + + fun hasActiveSubscription(): Boolean +} + +interface SubscriptionsListener { + + fun onActiveSubscription(renewalMessage: String?) + + fun onAvailableSubscription(skuDetails: SkuDetails) + + fun onBillingConnectionReady() + + fun onBillingConnectionError(message: String? = null) +} + +class SubscriptionManagerImpl( + private val context: Context, + private val listener: SubscriptionsListener +) : SubscriptionManager { + + private val acknowledgePurchaseListener = AcknowledgePurchaseResponseListener { billingResult: BillingResult -> + when (billingResult.responseCode) { + BillingClient.BillingResponseCode.OK -> { + Log.d(this, "acknowledgePurchaseResponseListener OK") + syncActiveSubscription() + } + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> { + // Billing API version is not supported for the type requested. + Log.d(this, "acknowledgePurchaseResponseListener BILLING_UNAVAILABLE") + } + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> { + // Network connection is down. + Log.d(this, "acknowledgePurchaseResponseListener SERVICE_UNAVAILABLE") + } + else -> { + // Handle any other error codes. + Log.d(this, "acknowledgePurchaseResponseListener ERROR - message: " + billingResult.debugMessage) + } + } + } + + /** + * Billing client listener triggered after every user purchase intent. + */ + private val purchaseUpdateListener = PurchasesUpdatedListener { billingResult: BillingResult, purchases: List? -> + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + Log.d(this, "purchaseUpdateListener OK") + for (purchase in purchases) { + handlePurchase(purchase) + } + } else if (billingResult.responseCode == BillingClient.BillingResponseCode.USER_CANCELED) { + // Handle an error caused by a user cancelling the purchase flow. + Log.d(this, "purchaseUpdateListener USER_CANCELLED") + } else if (billingResult.responseCode == BillingClient.BillingResponseCode.BILLING_UNAVAILABLE) { + // Billing API version is not supported for the type requested. + Log.d(this, "purchaseUpdateListener BILLING_UNAVAILABLE") + } else if (billingResult.responseCode == BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE) { + // Network connection is down. + Log.d(this, "purchaseUpdateListener SERVICE_UNAVAILABLE") + } else { + // Handle any other error codes. + Log.d(this, "purchaseUpdateListener ERROR - message: " + billingResult.debugMessage) + } + } + + private val billingClientStateListener: BillingClientStateListener = object : BillingClientStateListener { + override fun onBillingSetupFinished(billingResult: BillingResult) { + if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { + Log.d(this, "onBillingSetupFinished OK") + listener.onBillingConnectionReady() + } else { + listener.onBillingConnectionError("Error connecting to Play Store.") + } + } + + override fun onBillingServiceDisconnected() { + Log.d(this, "onBillingServiceDisconnected") + // Try to restart the connection on the next request to + // Google Play by calling the startConnection() method. + listener.onBillingConnectionError("Error connecting to Play Store.") + } + } + + private val billingClient: BillingClient = BillingClient.newBuilder(context) + .setListener(purchaseUpdateListener) + .enablePendingPurchases() + .build() + + override fun startBillingConnection() { + billingClient.startConnection(billingClientStateListener) + } + + override fun getSubscriptionState() = + if (hasActiveSubscription()) syncActiveSubscription() + else syncAvailableSubscription() + + override fun purchaseSubscription(activity: Activity, skuDetails: SkuDetails) { + Log.d(this, "launchBillingFlow for sku: ${skuDetails.sku}") + val billingFlowParams = BillingFlowParams.newBuilder() + .setSkuDetails(skuDetails) + .build() + billingClient.launchBillingFlow(activity, billingFlowParams) + } + + override fun syncActiveSubscription() { + val hasNewsBlurSubscription = PrefsUtils.getIsPremium(context) + val activePlayStoreSubscription = getActivePlayStoreSubscription() + + if (hasNewsBlurSubscription || activePlayStoreSubscription != null) { + val renewalString: String? = getRenewalMessage(activePlayStoreSubscription) + listener.onActiveSubscription(renewalString) + } + + if (!hasNewsBlurSubscription && activePlayStoreSubscription != null) { + saveSubscriptionReceipt(activePlayStoreSubscription) + } + } + + override fun hasActiveSubscription(): Boolean = + PrefsUtils.getIsPremium(context) || getActivePlayStoreSubscription() != null + + private fun getActivePlayStoreSubscription(): Purchase? { + val result = billingClient.queryPurchases(BillingClient.SkuType.SUBS) + return result.purchasesList?.let { + it.find { purchase -> purchase.sku == AppConstants.PREMIUM_SKU } + } + } + + private fun syncAvailableSubscription() { + val params = SkuDetailsParams.newBuilder().apply { + // add subscription SKUs from Play Store + setSkusList(listOf(AppConstants.PREMIUM_SKU)) + setType(BillingClient.SkuType.SUBS) + }.build() + + billingClient.querySkuDetailsAsync(params) { _: BillingResult?, skuDetailsList: List? -> + Log.d(this, "SkuDetailsResponse ${skuDetailsList.toString()}") + skuDetailsList?.let { + // Currently interested only in the premium yearly News Blur subscription. + val premiumSubscription = it.find { skuDetails -> + skuDetails.sku == AppConstants.PREMIUM_SKU + } + + premiumSubscription?.let { skuDetail -> + Log.d(this, skuDetail.toString()) + listener.onAvailableSubscription(skuDetail) + } ?: listener.onBillingConnectionError() + } + } + } + + private fun handlePurchase(purchase: Purchase) { + Log.d(this, "handlePurchase: ${purchase.orderId}") + if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && purchase.isAcknowledged) { + syncActiveSubscription() + } else if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED && !purchase.isAcknowledged) { + // need to acknowledge first time sub otherwise it will void + Log.d(this, "acknowledge purchase: ${purchase.orderId}") + AcknowledgePurchaseParams.newBuilder() + .setPurchaseToken(purchase.purchaseToken) + .build() + .also { + billingClient.acknowledgePurchase(it, acknowledgePurchaseListener) + } + } + } + + /** + * Notify backend of active Play Store subscription. + */ + private fun saveSubscriptionReceipt(purchase: Purchase) { + val apiManager = APIManager(context) + NBScope.executeAsyncTask( + doInBackground = { + apiManager.saveReceipt(purchase.orderId, purchase.sku) + }, + onPostExecute = { + if (!it.isError) { + NBSyncService.forceFeedsFolders() + FeedUtils.triggerSync(context) + } + } + ) + } + + /** + * Generate subscription renewal message. + */ + private fun getRenewalMessage(purchase: Purchase?): String? { + val expirationTimeMs = PrefsUtils.getPremiumExpire(context) + return when { + // lifetime subscription + expirationTimeMs == 0L -> { + context.getString(R.string.premium_subscription_no_expiration) + } + expirationTimeMs > 0 -> { + // date constructor expects ms + val expirationDate = Date(expirationTimeMs * 1000) + val dateFormat: DateFormat = SimpleDateFormat("EEE, MMMM d, yyyy", Locale.getDefault()) + dateFormat.timeZone = TimeZone.getDefault() + if (purchase != null && !purchase.isAutoRenewing) { + context.getString(R.string.premium_subscription_expiration, dateFormat.format(expirationDate)) + } else { + context.getString(R.string.premium_subscription_renewal, dateFormat.format(expirationDate)) + } + } + else -> null + } + } +} \ No newline at end of file