Subscription manager to handle billing and subscription syncs between Play Store and NewsBlur.

This commit is contained in:
sictiru 2021-12-27 22:35:31 -08:00
parent 7329f54d62
commit 5a17dec799
3 changed files with 304 additions and 188 deletions

View file

@ -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<Purchase>? ->
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<String> = 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<SkuDetails>? ->
Log.d(this@Premium.localClassName, "SkuDetailsResponse")
processSkuDetailsList(skuDetailsList)
}
}
private fun processSkuDetailsList(skuDetailsList: List<SkuDetails>?) {
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()
}
)
}
}
}

View file

@ -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;

View file

@ -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<Purchase>? ->
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<SkuDetails>? ->
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
}
}
}