From 15b2c6b3d604b8f46fd90d2bce26047e0f5ac094 Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 3 May 2019 14:37:30 +0200 Subject: [PATCH 1/5] first draft of IAP implementation --- app/build.gradle | 3 + app/src/main/AndroidManifest.xml | 6 + .../android/PokerAnalyticsApplication.kt | 5 +- .../android/ui/activity/BillingActivity.kt | 36 ++++++ .../ui/fragment/SubscriptionFragment.kt | 68 +++++++++++ .../android/util/billing/AppGuard.kt | 110 ++++++++++++++++++ app/src/main/res/layout/activity_billing.xml | 15 +++ .../main/res/layout/fragment_subscription.xml | 19 +++ app/src/main/res/values/strings.xml | 1 + 9 files changed, 262 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/net/pokeranalytics/android/ui/activity/BillingActivity.kt create mode 100644 app/src/main/java/net/pokeranalytics/android/ui/fragment/SubscriptionFragment.kt create mode 100644 app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt create mode 100644 app/src/main/res/layout/activity_billing.xml create mode 100644 app/src/main/res/layout/fragment_subscription.xml diff --git a/app/build.gradle b/app/build.gradle index c54b385d..8ee8c89d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -87,6 +87,9 @@ dependencies { // Places implementation 'com.google.android.libraries.places:places:1.1.0' + // Billing / Subscriptions + implementation 'com.android.billingclient:billing:1.2.2' + // Firebase implementation 'com.google.firebase:firebase-core:16.0.8' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8c2989ed..123dffcf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + diff --git a/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt b/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt index 33b16f46..5ca7c7f0 100644 --- a/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt +++ b/app/src/main/java/net/pokeranalytics/android/PokerAnalyticsApplication.kt @@ -26,6 +26,9 @@ class PokerAnalyticsApplication : Application() { super.onCreate() UserDefaults.init(this) + // AppGuard / Billing services +// AppGuard.load(this.applicationContext) + // Realm Realm.init(this) val realmConfiguration = RealmConfiguration.Builder() @@ -52,7 +55,7 @@ class PokerAnalyticsApplication : Application() { if (BuildConfig.DEBUG) { Timber.d("UserPreferences.defaultCurrency: ${UserDefaults.currency.symbol}") - this.createFakeSessions() +// this.createFakeSessions() } Patcher.patchBreaks() diff --git a/app/src/main/java/net/pokeranalytics/android/ui/activity/BillingActivity.kt b/app/src/main/java/net/pokeranalytics/android/ui/activity/BillingActivity.kt new file mode 100644 index 00000000..9a313130 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/ui/activity/BillingActivity.kt @@ -0,0 +1,36 @@ +package net.pokeranalytics.android.ui.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import net.pokeranalytics.android.R +import net.pokeranalytics.android.ui.activity.components.PokerAnalyticsActivity +import net.pokeranalytics.android.util.billing.AppGuard + +enum class IAPProducts(var identifier: String) { + PRO("pro") +} + +class BillingActivity : PokerAnalyticsActivity() { + + companion object { + fun newInstance(context: Context) { + val intent = Intent(context, BillingActivity::class.java) + context.startActivity(intent) + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_billing) + } + + override fun onResume() { + super.onResume() + + AppGuard.restorePurchases() + } + +} + + diff --git a/app/src/main/java/net/pokeranalytics/android/ui/fragment/SubscriptionFragment.kt b/app/src/main/java/net/pokeranalytics/android/ui/fragment/SubscriptionFragment.kt new file mode 100644 index 00000000..b7a8cdcc --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/ui/fragment/SubscriptionFragment.kt @@ -0,0 +1,68 @@ +package net.pokeranalytics.android.ui.fragment + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.SkuDetails +import com.android.billingclient.api.SkuDetailsResponseListener +import kotlinx.android.synthetic.main.fragment_subscription.* +import net.pokeranalytics.android.R +import net.pokeranalytics.android.ui.activity.IAPProducts +import net.pokeranalytics.android.ui.fragment.components.PokerAnalyticsFragment +import net.pokeranalytics.android.util.billing.AppGuard + +class SubscriptionFragment : PokerAnalyticsFragment(), SkuDetailsResponseListener { + + var selectedProduct: SkuDetails? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + if (!AppGuard.requestProducts(this)) { + Toast.makeText(requireContext(), R.string.billingclient_unavailable, Toast.LENGTH_LONG).show() + } + + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflater.inflate(R.layout.fragment_subscription, container, false) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + initUI() + + } + + // SkuDetailsResponseListener + override fun onSkuDetailsResponse(responseCode: Int, skuDetailsList: MutableList?) { + + if (responseCode == BillingClient.BillingResponse.OK && skuDetailsList != null) { + selectedProduct = skuDetailsList.first { it.sku == IAPProducts.PRO.identifier } + updatePurchaseButtonState() + } + + } + + private fun initUI() { + + this.purchase.setOnClickListener { + + this.selectedProduct?.let { + AppGuard.initiatePurchase(this.requireActivity(), it) + } ?: run { + throw IllegalStateException("Attempt to initiate purchase while no product has been chosen") + } + } + + } + + private fun updatePurchaseButtonState() { + this.purchase.isEnabled = (this.selectedProduct != null) + } + +} \ No newline at end of file diff --git a/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt new file mode 100644 index 00000000..4d1e16d4 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt @@ -0,0 +1,110 @@ +package net.pokeranalytics.android.util.billing + +import android.app.Activity +import android.content.Context +import com.android.billingclient.api.* +import net.pokeranalytics.android.ui.activity.IAPProducts + +object AppGuard : PurchasesUpdatedListener { + + lateinit private var billingClient: BillingClient + + private var billingClientAvailable: Boolean = false + + fun load(context: Context) { + + billingClient = BillingClient.newBuilder(context).setListener(this).build() + + this.restorePurchases() + + billingClient.startConnection(object : BillingClientStateListener { + override fun onBillingSetupFinished(@BillingClient.BillingResponse billingResponseCode: Int) { + if (billingResponseCode == BillingClient.BillingResponse.OK) { + // The BillingClient is ready. You can query purchases here. + + billingClientAvailable = true + + } + } + override fun onBillingServiceDisconnected() { + billingClientAvailable = false + + // Try to restart the connection on the next request to + // Google Play by calling the startConnection() method. + } + }) + + } + + fun restorePurchases() { + // Automatically checks for purchases (when switching devices for example) + val purchasesResult= + billingClient.queryPurchases(BillingClient.SkuType.SUBS) + purchasesResult.purchasesList.forEach { + this.handlePurchase(it) + } + } + + fun requestProducts(listener: SkuDetailsResponseListener) : Boolean { + + if (this.billingClientAvailable) { + + val skuList = ArrayList() + skuList.add(IAPProducts.PRO.identifier) + val params = SkuDetailsParams.newBuilder() + params.setSkusList(skuList).setType(BillingClient.SkuType.SUBS) + + billingClient.querySkuDetailsAsync(params.build(), listener) + return true + } + return false + } + + fun initiatePurchase(activity: Activity, skuDetails: SkuDetails) { + + val flowParams = BillingFlowParams.newBuilder() + .setSkuDetails(skuDetails) + .build() + val responseCode = billingClient.launchBillingFlow(activity, flowParams) + + } + + // PurchasesUpdatedListener + + override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList?) { + + if (responseCode == BillingClient.BillingResponse.OK && purchases != null) { + for (purchase in purchases) { + handlePurchase(purchase) + } + } else if (responseCode == BillingClient.BillingResponse.USER_CANCELED) { + // Handle an error caused by a user cancelling the purchase flow. + } else { + // Handle any other error codes. + } + + } + + private fun handlePurchase(purchase: Purchase) { + + val token = purchase.purchaseToken + val oj = purchase.originalJson + + if (this.verifyPurchase(purchase)) { + when (purchase.sku) { + IAPProducts.PRO.identifier -> { + + } + else -> {} + } + } else { + // invalid purchase + } + + } + + private fun verifyPurchase(purchase: Purchase) : Boolean { + return true + } + +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_billing.xml b/app/src/main/res/layout/activity_billing.xml new file mode 100644 index 00000000..08d1f651 --- /dev/null +++ b/app/src/main/res/layout/activity_billing.xml @@ -0,0 +1,15 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/fragment_subscription.xml b/app/src/main/res/layout/fragment_subscription.xml new file mode 100644 index 00000000..90b15285 --- /dev/null +++ b/app/src/main/res/layout/fragment_subscription.xml @@ -0,0 +1,19 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 73666f07..26f159bb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,7 @@ Initial Value Can\'t show because there is less than two values to display! The object you\'re trying to access is invalid + The billing service is unavailable at the moment. Please check your internet connection and retry later. Address Naming suggestions From c487189d463ec48877ca8aa564c4d3a93ceb6431 Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 3 May 2019 14:54:46 +0200 Subject: [PATCH 2/5] Improves bankroll graph --- .../android/calculus/bankroll/BankrollReport.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/net/pokeranalytics/android/calculus/bankroll/BankrollReport.kt b/app/src/main/java/net/pokeranalytics/android/calculus/bankroll/BankrollReport.kt index c37b6b66..33cb6cd8 100644 --- a/app/src/main/java/net/pokeranalytics/android/calculus/bankroll/BankrollReport.kt +++ b/app/src/main/java/net/pokeranalytics/android/calculus/bankroll/BankrollReport.kt @@ -203,8 +203,8 @@ class BankrollReport(var setup: BankrollReportSetup) : RowRepresentable { fun lineDataSet(context: Context): LineDataSet { val entries = mutableListOf() - this.evolutionPoints.forEach { - val entry = Entry(it.date.time.toFloat(), it.value.toFloat(), it.data) + this.evolutionPoints.forEachIndexed { index, point -> + val entry = Entry(index.toFloat(), point.value.toFloat(), point.data) entries.add(entry) } return DataSetFactory.lineDataSetInstance(entries, "", context) From 8cfcedd4c0f1a2683623d138b17fc753ea6f2315 Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 3 May 2019 14:55:31 +0200 Subject: [PATCH 3/5] Improves session UX --- .../net/pokeranalytics/android/ui/fragment/SessionFragment.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/net/pokeranalytics/android/ui/fragment/SessionFragment.kt b/app/src/main/java/net/pokeranalytics/android/ui/fragment/SessionFragment.kt index d80eb28b..ed1dc111 100644 --- a/app/src/main/java/net/pokeranalytics/android/ui/fragment/SessionFragment.kt +++ b/app/src/main/java/net/pokeranalytics/android/ui/fragment/SessionFragment.kt @@ -40,6 +40,7 @@ class SessionFragment : PokerAnalyticsFragment(), RowRepresentableDelegate { private val oldRows: ArrayList = ArrayList() private var sessionHasBeenCustomized = false private val handler: Handler = Handler() + private val refreshTimer: Runnable = object : Runnable { override fun run() { // Refresh header each 30 seconds @@ -263,6 +264,7 @@ class SessionFragment : PokerAnalyticsFragment(), RowRepresentableDelegate { when (currentSession.getState()) { SessionState.PENDING, SessionState.PLANNED, SessionState.PAUSED -> { currentSession.startOrContinue() + this.recyclerView.smoothScrollToPosition(0) } SessionState.STARTED -> { currentSession.pause() From ff8e2cdbaee199a45a546eee93a9a18bbd2b6192 Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 3 May 2019 15:18:44 +0200 Subject: [PATCH 4/5] Add comments to the class --- .../android/util/billing/AppGuard.kt | 50 +++++++++++++++++-- 1 file changed, 45 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt index 4d1e16d4..93106650 100644 --- a/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt +++ b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt @@ -5,12 +5,32 @@ import android.content.Context import com.android.billingclient.api.* import net.pokeranalytics.android.ui.activity.IAPProducts +/** + * the AppGuard object is in charge of contacting the Billing services to retrieve products, + * initiating purchases and verifying transactions + */ object AppGuard : PurchasesUpdatedListener { + /** + * The Billing Client making requests with Google Billing services + */ lateinit private var billingClient: BillingClient + /** + * Whether the billing client is available + */ private var billingClientAvailable: Boolean = false + /** + * Returns whether the user has the pro subscription + */ + var isProUser: Boolean = false + private set + + /** + * Initialization of AppGuard + * Connects to billing services and restores purchases + */ fun load(context: Context) { billingClient = BillingClient.newBuilder(context).setListener(this).build() @@ -26,6 +46,7 @@ object AppGuard : PurchasesUpdatedListener { } } + override fun onBillingServiceDisconnected() { billingClientAvailable = false @@ -36,16 +57,22 @@ object AppGuard : PurchasesUpdatedListener { } + /** + * Restore purchases + */ fun restorePurchases() { // Automatically checks for purchases (when switching devices for example) - val purchasesResult= + val purchasesResult = billingClient.queryPurchases(BillingClient.SkuType.SUBS) purchasesResult.purchasesList.forEach { this.handlePurchase(it) } } - fun requestProducts(listener: SkuDetailsResponseListener) : Boolean { + /** + * Requests the product descriptions + */ + fun requestProducts(listener: SkuDetailsResponseListener): Boolean { if (this.billingClientAvailable) { @@ -60,6 +87,9 @@ object AppGuard : PurchasesUpdatedListener { return false } + /** + * Initiates purchase with the product [skuDetails] + */ fun initiatePurchase(activity: Activity, skuDetails: SkuDetails) { val flowParams = BillingFlowParams.newBuilder() @@ -71,6 +101,9 @@ object AppGuard : PurchasesUpdatedListener { // PurchasesUpdatedListener + /** + * Purchase callback + */ override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList?) { if (responseCode == BillingClient.BillingResponse.OK && purchases != null) { @@ -85,6 +118,9 @@ object AppGuard : PurchasesUpdatedListener { } + /** + * Method called when a purchase has been made + */ private fun handlePurchase(purchase: Purchase) { val token = purchase.purchaseToken @@ -93,9 +129,10 @@ object AppGuard : PurchasesUpdatedListener { if (this.verifyPurchase(purchase)) { when (purchase.sku) { IAPProducts.PRO.identifier -> { - + this.isProUser = true + } + else -> { } - else -> {} } } else { // invalid purchase @@ -103,7 +140,10 @@ object AppGuard : PurchasesUpdatedListener { } - private fun verifyPurchase(purchase: Purchase) : Boolean { + /** + * Verifies the validity of a purchase + */ + private fun verifyPurchase(purchase: Purchase): Boolean { return true } From 7448d73b19d2ef7a44f05e94697b42cb1bfecd31 Mon Sep 17 00:00:00 2001 From: Laurent Date: Fri, 3 May 2019 15:29:09 +0200 Subject: [PATCH 5/5] Implements billing service disconnection logic --- .../android/util/billing/AppGuard.kt | 34 ++++++++++++++----- 1 file changed, 26 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt index 93106650..0fb29408 100644 --- a/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt +++ b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt @@ -35,28 +35,40 @@ object AppGuard : PurchasesUpdatedListener { billingClient = BillingClient.newBuilder(context).setListener(this).build() - this.restorePurchases() + this.startConnection(Runnable { + this.restorePurchases() + }) + + } + + private fun startConnection(executeOnSuccess: Runnable) { billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(@BillingClient.BillingResponse billingResponseCode: Int) { if (billingResponseCode == BillingClient.BillingResponse.OK) { // The BillingClient is ready. You can query purchases here. - billingClientAvailable = true - + executeOnSuccess.run() } } override fun onBillingServiceDisconnected() { billingClientAvailable = false - - // Try to restart the connection on the next request to - // Google Play by calling the startConnection() method. } }) } + private fun executeServiceRequest(runnable: Runnable) { + + if (billingClientAvailable) { + runnable.run() + } else { + this.startConnection(runnable) + } + + } + /** * Restore purchases */ @@ -81,7 +93,10 @@ object AppGuard : PurchasesUpdatedListener { val params = SkuDetailsParams.newBuilder() params.setSkusList(skuList).setType(BillingClient.SkuType.SUBS) - billingClient.querySkuDetailsAsync(params.build(), listener) + this.executeServiceRequest(Runnable { + billingClient.querySkuDetailsAsync(params.build(), listener) + }) + return true } return false @@ -95,7 +110,10 @@ object AppGuard : PurchasesUpdatedListener { val flowParams = BillingFlowParams.newBuilder() .setSkuDetails(skuDetails) .build() - val responseCode = billingClient.launchBillingFlow(activity, flowParams) + + this.executeServiceRequest(Runnable { + val responseCode = billingClient.launchBillingFlow(activity, flowParams) + }) }