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 44851fd3..dcb562ca 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 6a1d8b1c..2ed38ca4 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/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) 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/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() 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..0fb29408 --- /dev/null +++ b/app/src/main/java/net/pokeranalytics/android/util/billing/AppGuard.kt @@ -0,0 +1,168 @@ +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 + +/** + * 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() + + 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 + } + }) + + } + + private fun executeServiceRequest(runnable: Runnable) { + + if (billingClientAvailable) { + runnable.run() + } else { + this.startConnection(runnable) + } + + } + + /** + * Restore purchases + */ + fun restorePurchases() { + // Automatically checks for purchases (when switching devices for example) + val purchasesResult = + billingClient.queryPurchases(BillingClient.SkuType.SUBS) + purchasesResult.purchasesList.forEach { + this.handlePurchase(it) + } + } + + /** + * Requests the product descriptions + */ + 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) + + this.executeServiceRequest(Runnable { + billingClient.querySkuDetailsAsync(params.build(), listener) + }) + + return true + } + return false + } + + /** + * Initiates purchase with the product [skuDetails] + */ + fun initiatePurchase(activity: Activity, skuDetails: SkuDetails) { + + val flowParams = BillingFlowParams.newBuilder() + .setSkuDetails(skuDetails) + .build() + + this.executeServiceRequest(Runnable { + val responseCode = billingClient.launchBillingFlow(activity, flowParams) + }) + + } + + // PurchasesUpdatedListener + + /** + * Purchase callback + */ + 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. + } + + } + + /** + * Method called when a purchase has been made + */ + private fun handlePurchase(purchase: Purchase) { + + val token = purchase.purchaseToken + val oj = purchase.originalJson + + if (this.verifyPurchase(purchase)) { + when (purchase.sku) { + IAPProducts.PRO.identifier -> { + this.isProUser = true + } + else -> { + } + } + } else { + // invalid purchase + } + + } + + /** + * Verifies the validity of a 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