diff --git a/.github/workflows/secret.scan.yml b/.github/workflows/secret.scan.yml new file mode 100644 index 0000000..e08ac36 --- /dev/null +++ b/.github/workflows/secret.scan.yml @@ -0,0 +1,32 @@ +name: CB Secret PR Scan + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + scan: + name: Secret PR Scan + runs-on: ubuntu-latest + steps: + - name: Get the file name changed in the PR + id: pr_files + run: | + changed_files=$(curl -s -H "Authorization: token ${{ + secrets.GITHUB_TOKEN }}" \ + -H "Accept: application/vnd.github.v3+json" \ + https://api.github.com/repos/${{ github.repository }}/pulls/${{ github.event.pull_request.number }}/files | jq -r '.[].filename') + echo "Changed files: $changed_files" + + echo "CHANGED_FILES<> $GITHUB_OUTPUT + + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - uses: gitleaks/gitleaks-action@v2 + env: + GITLEAKS_ENABLE_UPLOAD_ARTIFACT: false + GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' + GITLEAKS_LICENSE: '${{ secrets.GITLEAKS_LICENSE }}' + GITLEAKS_ENABLE_SUMMARY: false diff --git a/README.md b/README.md index 4842328..80fd21c 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,17 @@ -# Chargebee Android +> [!WARNING] +> **Chargebee Android SDK** is part of Chargebee's legacy Mobile Subscription Solution and will not include the latest enhancements. We recommend using Chargebee's new [Omnichannel Subscriptions](https://www.chargebee.com/docs/billing/2.0/mobile-subscriptions/omnichannel-subscription-overview) solution for improved reliability and unified mobile subscriptions experience. +If you are a new customer, or considering to migrate new solution, please reach out to support@chargebee.com or contact your Technical Success Manager (TSM) for guidance. + +Chargebee Android (Legacy) +============= + +> [!NOTE] +> #### Updates for Billing Library 5 +> - SDK Version 2.0: This version includes Google Billing Library 6.2.1 but still uses Google Billing Library 5.0 APIs to fetch product information from the Google Play Console and make purchases. If you’re integrating Chargebee’s SDK for the first time, then use this version, and if you’re migrating from the older version of SDK to this version, follow the migration steps in this [document](https://www.chargebee.com/docs/2.0/mobile-playstore-billing-library-5.html). +> - SDK Version 1.2.2: This [version](https://github.com/chargebee/chargebee-android/tree/1.x.x) includes Billing Library 6.2.1 but still uses Billing Library 4.0 APIs to fetch product information from the Google Play Console and make purchases. This will enable you to list or update your Android app on the store without any warnings from Google and give you enough time to migrate to version 2.0. +> - SDK Version 1.1.0: This and less than this version of SDKs use billing library 4.0 APIs that are deprecated by Google. Therefore, it is highly recommended that you upgrade your app and integrate it with SDK version 1.2.0 and above. + + This is Chargebee’s Android Software Development Kit (SDK). This SDK makes it efficient and comfortable to build a seamless subscription experience in your Android app. Post-installation, initialization, and authentication with the Chargebee site, this SDK will support the following process. @@ -11,8 +24,8 @@ Post-installation, initialization, and authentication with the Chargebee site, t ## Requirements The following requirements must be set up before installing Chargebee’s Android SDK. -* Android 5.0 (API level 21) and above -* [Android Gradle Plugin](https://developer.android.com/studio/releases/gradle-plugin) 4.0.0 +* Android Target API Level 31 and above +* [Android Gradle Plugin](https://developer.android.com/studio/releases/gradle-plugin) 4.2.2 * [Gradle](https://gradle.org/releases/) 6.1.1+ * [AndroidX](https://developer.android.com/jetpack/androidx/) * Java 8+ and Kotlin @@ -21,7 +34,7 @@ The following requirements must be set up before installing Chargebee’s Androi The `Chargebee-Android` SDK can be installed by adding below dependency to the `build.gradle` file: ```kotlin -implementation 'com.chargebee:chargebee-android:1.0.16' +implementation 'com.chargebee:chargebee-android:2.0.0-beta-4' ``` ## Example project @@ -55,9 +68,21 @@ To configure the Chargebee Android SDK for completing and managing In-App Purcha ```kotlin import com.chargebee.android.Chargebee -Chargebee.configure(site= "your-site", - publishableApiKey= "api_key", - sdkKey= "sdk_key",packageName = "packageName") +Chargebee.configure( + site = "your-site", + publishableApiKey = "api-key", + sdkKey = "sdk-key", + packageName = "your-package" +) { + when (it) { + is ChargebeeResult.Success -> { + // Success + } + is ChargebeeResult.Error -> { + // Error + } + } +} ``` ### Configuration for credit card using tokenization To configure SDK only for tokenizing credit card details, follow these steps. @@ -69,7 +94,7 @@ To configure SDK only for tokenizing credit card details, follow these steps. ```kotlin import com.chargebee.android.Chargebee -Chargebee.configure(site = "your-site", publishableApiKey = "api_key") +Chargebee.configure(site = "your-site", publishableApiKey = "api-key") ``` ## Integration @@ -86,7 +111,7 @@ The following section describes how to use the SDK to integrate In-App Purchase Every In-App Purchase subscription product that you configure in your Play Store account, can be configured in Chargebee as a Plan. Start by retrieving the Google IAP Product IDs from your Chargebee account. ```kotlin -CBPurchase.retrieveProductIdentifers(queryParam) { +CBPurchase.retrieveProductIdentifiers(queryParam) { when (it) { is CBProductIDResult.ProductIds -> { Log.i(TAG, "List of Product Identifiers: $it") @@ -106,29 +131,29 @@ The above function will determine your product catalog version in Chargebee and Retrieve the Google IAP Product using the following function. ```kotlin -CBPurchase.retrieveProducts(this, productIdList= "[Product ID's from Google Play Console]", - object : CBCallback.ListProductsCallback> { - override fun onSuccess(productDetails: ArrayList) { +CBPurchase.retrieveProducts(activity, productIdList= ["Product ID's from Google Play Console"], + object : CBCallback.ListProductsCallback> { + override fun onSuccess(productDetails: ArrayList) { Log.i(TAG, "List of Products: $productDetails") } override fun onError(error: CBException) { Log.e(TAG, "Error: ${error.message}") - // Handle error here } }) - ``` You can present any of the above products to your users for them to purchase. ### Buy or Subscribe Product -Pass the `CBProduct` and `CBCustomer` objects to the following function when the user chooses the product to purchase. +Pass the `PurchaseProductParams`, `CBCustomer` and `OfferToken` to the following function when the user chooses the product to purchase. `CBCustomer` - **Optional object**. Although this is an optional object, we recommend passing the necessary customer details, such as `customerId`, `firstName`, `lastName`, and `email` if it is available before the user subscribes to your App. This ensures that the customer details in your database match the customer details in Chargebee. If the `customerId` is not passed in the customer's details, then the value of `customerId` will be the same as the `SubscriptionId` created in Chargebee. **Note**: The `customer` parameter in the below code snippet is an instance of `CBCustomer` class that contains the details of the customer who wants to subscribe or buy the product. ```kotlin -CBPurchase.purchaseProduct(product=CBProduct, customer=CBCustomer, object : CBCallback.PurchaseCallback{ +val purchaseParams = PurchaseProductParams(selectedCBProduct, "selectedOfferToken") +val cbCustomer = CBCustomer("customerId","firstName","lastName","email") +CBPurchase.purchaseProduct(purchaseProductParams = purchaseProductParams, customer = cbCustomer, object : CBCallback.PurchaseCallback{ override fun onSuccess(result: ReceiptDetail, status:Boolean) { Log.i(TAG, "$status") Log.i(TAG, "${result.subscription_id}") @@ -141,6 +166,112 @@ CBPurchase.purchaseProduct(product=CBProduct, customer=CBCustomer, object : CBCa ``` The above function will handle the purchase against Google Play Store and send the IAP token for server-side token verification to your Chargebee account. Use the Subscription ID returned by the above function, to check for Subscription status on Chargebee and confirm the access - granted or denied. +### Invoke Manage Subscriptions in your App +The `showManageSubscriptionsSettings()` function is designed to invoke the Manage Subscriptions in your app using Chargebee's Android SDKs. `Chargebee.showManageSubscriptionsSettings()`, opens the Play Store App subscriptions settings page. + +### One-Time Purchases +The `purchaseNonSubscriptionProduct` function handles the one-time purchase against Google Play Store and sends the IAP receipt for server-side receipt verification to your Chargebee account. Post verification a Charge corresponding to this one-time purchase will be created in Chargebee. There are two types of one-time purchases `consumable` and `non_consumable`. + +```kotlin +CBPurchase.purchaseNonSubscriptionProduct(product = CBProduct, customer = CBCustomer, productType = OneTimeProductType.CONSUMABLE, callback = object : CBCallback.OneTimePurchaseCallback{ + override fun onSuccess(result: NonSubscription, status:Boolean) { + Log.i(TAG, "invoice ID: ${result.invoiceId}") + Log.i(TAG, "charge ID: ${result.chargeId}") + Log.i(TAG, "customer ID: ${result.customerId}") + } + override fun onError(error: CBException) { + Log.e(TAG, "Error: ${error.message}") + } +}) + ``` + +The given code defines a function named `purchaseNonSubscriptionProduct` in the CBPurchase class, which takes four input parameters: + +- `product`: An instance of `CBProduct` class, representing the product to be purchased from the Google Play Store. +- `customer`: Optional. An instance of `CBCustomer` class, initialized with the customer's details such as `customerId`, `firstName`, `lastName`, and `email`. +- `productType`: An enum instance of `productType` type, indicating the type of product to be purchased. It can be either .`consumable`, or `non_consumable`. +- `callback`: The `OneTimePurchaseCallback` listener will be invoked when product purchase completes. + +The function is called asynchronously, and it returns a `Result` object with a `success` or `failure` case, which can be handled in the listener. +- If the purchase is successful, the listener will be called with the `success` case, it returns `NonSubscriptionResponse` object. which includes the `customerId`, `chargeId`, and `invoiceId` associated with the purchase. +- If there is any failure during the purchase, the listener will be called with the `error` case, it returns `CBException`. which includes an error object that can be used to handle the error. + +### Restore Purchase + +The `restorePurchases()` function helps to recover your app user's previous purchases without making them pay again. Sometimes, your app user may want to restore their previous purchases after switching to a new device or reinstalling your app. You can use the `restorePurchases()` function to allow your app user to easily restore their previous purchases. + +To retrieve **inactive** purchases along with the **active** purchases for your app user, you can call the `restorePurchases()` function with the `includeInActiveProducts` parameter set to `true`. If you only want to restore active subscriptions, set the parameter to `false`. Here is an example of how to use the `restorePurchases()` function in your code with the `includeInActiveProducts` parameter set to `true`. + +`CBCustomer` - **Optional object**. Although this is an optional object, we recommend passing the necessary customer details, such as `customerId`, `firstName`, `lastName`, and `email` if it is available before the user subscribes to your App. This ensures that the customer details in your database match the customer details in Chargebee. If the `customerId` is not passed in the customer's details, then the value of `customerId` will be the same as the `subscriptionId` created in Chargebee. Also, the restored subscriptions will not be associate with existing customerId. + +```kotlin +CBPurchase.restorePurchases(context = current activity context, customer = CBCustomer, includeInActivePurchases = false, object : CBCallback.RestorePurchaseCallback{ + override fun onSuccess(result: List) { + result.forEach { + Log.i(javaClass.simpleName, "Successfully restored purchases") + } + } + override fun onError(error: CBException) { + Log.e(TAG, "Error: ${error.message}") + } +}) + ``` + +##### Return Subscriptions Object +The `restorePurchases()` function returns an array of subscription objects and each object holds three attributes `subscription_id`, `plan_id`, and `store_status`. The value of `store_status` can be used to verify the subscription status such as `Active`, `InTrial`, `Cancelled` and `Paused`. + +##### Error Handling +In the event of any failures while finding associated subscriptions for the restored items, The SDK will return an error, as mentioned in the following table. + +These are the possible error codes and their descriptions: +| Error Code | Description | +|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| `BillingErrorCode.SERVICE_TIMEOUT` | The request has reached the maximum timeout before Google Play responds. | +| `BillingErrorCode.FEATURE_NOT_SUPPORTED` | The requested feature is not supported by the Play Store on the current device. | +| `BillingErrorCode.SERVICE_UNAVAILABLE` | The service is currently unavailable. | +| `BillingErrorCode.DEVELOPER_ERROR` | Error resulting from incorrect usage of the API. | +| `BillingErrorCode.ERROR` | Fatal error during the API action. | +| `BillingErrorCode.SERVICE_DISCONNECTED` | The app is not connected to the Play Store service via the Google Play Billing Library. | +| `BillingErrorCode.UNKNOWN` | Unknown error occurred. | + +##### Synchronization of Google Play Store Purchases with Chargebee through Receipt Validation +Receipt validation is crucial to ensure that the purchases made by your users are synced with Chargebee. In rare cases, when a purchase is made at the Google Play Store, and the network connection goes off or the server not responding, the purchase details may not be updated in Chargebee. In such cases, you can use a retry mechanism by following these steps: + +* Add a network listener, as shown in the example project. +* Save the product identifier in the cache once the purchase is initiated and clear the cache once the purchase is successful. +* When the network connectivity is lost after the purchase is completed at Google Play Store but not synced with Chargebee, retrieve the product from the cache once the network connection is back and initiate `validateReceipt() / validateReceiptForNonSubscriptions()` by passing activity `Context`, `CBProduct` and `CBCustomer(optional)` as input. This will validate the receipt and sync the purchase in Chargebee as a subscription or one-time purchase. For subscriptions, use the function to `validateReceipt()`;for one-time purchases, use the function `validateReceiptForNonSubscriptions()`. + +Use the function available for the retry mechanism. +##### Function for validating the Subscriptions receipt + +```kotlin +CBPurchase.validateReceipt(context = current activity context, product = CBProduct, customer = CBCustomer, object : CBCallback.PurchaseCallback { + override fun onSuccess(result: ReceiptDetail, status: Boolean) { + Log.i(TAG, "$status") + Log.i(TAG, "${result.subscription_id}") + Log.i(TAG, "${result.plan_id}") + } + override fun onError(error: CBException) { + Log.e(TAG, "Error: ${error.message}") + } +}) + ``` + +##### Function for validating the One-Time Purchases receipt + +```kotlin +CBPurchase.validateReceiptForNonSubscriptions(context = current activity context, product = CBProduct, customer = CBCustomer, productType = OneTimeProductType.CONSUMABLE, object : CBCallback.OneTimePurchaseCallback { + override fun onSuccess(result: NonSubscription, status: Boolean) { + Log.i(TAG, "invoice ID: ${result.invoiceId}") + Log.i(TAG, "charge ID: ${result.chargeId}") + Log.i(TAG, "customer ID: ${result.customerId}") + } + override fun onError(error: CBException) { + Log.e(TAG, "Error: ${error.message}") + } +}) + ``` + ### Get Subscription Status for Existing Subscribers The following are methods for checking the subscription status of a subscriber who already purchased the product. @@ -329,6 +460,7 @@ Once your customer’s card data is processed and stored, and a Chargebee token Please refer to the [Chargebee API Docs](https://apidocs.chargebee.com/docs/api) for subsequent integration steps. + ## License Chargebee is available under the [MIT license](https://opensource.org/licenses/MIT). See the LICENSE file for more info. @@ -367,7 +499,7 @@ Chargebee is available under the [MIT license](https://opensource.org/licenses/M To install Chargebee's Android SDK, add the following dependency to the build.gradle file. ``` - implementation 'com.chargebee:chargebee-android:1.0.16' + implementation 'com.chargebee:chargebee-android:1.0.25' ``` Example project --------------- @@ -492,9 +624,7 @@ The above function will determine your product catalog version in Chargebee and The above function will handle the purchase against Google Play Store and send the IAP token for server-side token verification to your Chargebee account. Use the Subscription ID returned by the above function, to check for Subscription status on Chargebee and confirm the access - granted or denied. - ##### Returns Plan Object - - This function returns the plan ID associated with a subscription. You can associate JSON metadata with the Google Play Store plans in Chargebee and retrieve the same by passing plan ID to the SDK method - [retrievePlan](https://github.com/chargebee/chargebee-android#get-plan-details)(PC 1.0) or [retrieveItem](https://github.com/chargebee/chargebee-android#get-item-details)(PC 2.0). + This function also returns the plan ID associated with a subscription. You can associate JSON metadata with the Google Play Store plans in Chargebee and retrieve the same by passing plan ID to the SDK method - [retrievePlan](https://github.com/chargebee/chargebee-android#get-plan-details)(PC 1.0) or [retrieveItem](https://github.com/chargebee/chargebee-android#get-item-details)(PC 2.0). #### Get Subscription Status for Existing Subscribers diff --git a/app/build.gradle b/app/build.gradle index 85be433..0f20f06 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -1,19 +1,18 @@ apply plugin: 'com.android.application' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' apply plugin: 'kotlin-kapt' android { - compileSdkVersion 30 + compileSdkVersion 33 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } defaultConfig { applicationId "com.chargebee.example" - minSdkVersion 21 - targetSdkVersion 30 - versionCode 3 + minSdkVersion 24 + targetSdkVersion 33 + versionCode 6 versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -21,7 +20,7 @@ android { buildTypes { release { - minifyEnabled false + minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' } } @@ -41,7 +40,7 @@ dependencies { implementation 'com.google.android.material:material:1.1.0' testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' implementation 'com.google.code.gson:gson:2.8.8' diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 89f51ce..0ff6c0a 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -17,12 +17,13 @@ - - - - - - + + + + + + diff --git a/app/src/main/java/com/chargebee/example/ExampleApplication.kt b/app/src/main/java/com/chargebee/example/ExampleApplication.kt index e6b554a..fc678ac 100644 --- a/app/src/main/java/com/chargebee/example/ExampleApplication.kt +++ b/app/src/main/java/com/chargebee/example/ExampleApplication.kt @@ -2,22 +2,112 @@ package com.chargebee.example import android.app.Application import android.content.Context -import android.net.ConnectivityManager -import com.chargebee.android.Chargebee +import android.content.SharedPreferences +import android.util.Log +import com.chargebee.android.billingservice.CBCallback +import com.chargebee.android.billingservice.CBPurchase +import com.chargebee.android.billingservice.OneTimeProductType +import com.chargebee.android.billingservice.ProductType +import com.chargebee.android.exceptions.CBException +import com.chargebee.android.models.CBProduct +import com.chargebee.android.models.NonSubscription +import com.chargebee.android.network.CBCustomer +import com.chargebee.android.network.ReceiptDetail +import com.chargebee.example.util.NetworkUtil -class ExampleApplication: Application() { +class ExampleApplication : Application(), NetworkUtil.NetworkListener { + private lateinit var networkUtil: NetworkUtil + private var sharedPreference: SharedPreferences? = null + lateinit var mContext: Context + private val customer = CBCustomer( + id = "sync_receipt_android", + firstName = "Test", + lastName = "Purchase", + email = "testreceipt@gmail.com" + ) override fun onCreate() { super.onCreate() + mContext = this + networkUtil = NetworkUtil(mContext, this) + networkUtil.registerCallbackEvents() + sharedPreference = mContext.getSharedPreferences("PREFERENCE_NAME", Context.MODE_PRIVATE) + } + + override fun onNetworkConnectionAvailable() { + val productId = sharedPreference?.getString("productId", "") + if (productId?.isNotEmpty() == true) { + val productList = ArrayList() + productList.add(productId) + retrieveProducts(productList) + } + } + + override fun onNetworkConnectionLost() { + Log.e(javaClass.simpleName, "Network connectivity not available") + } - if (isInternetAvailable(this)) - // Please add site/app details as required - Chargebee.configure(site = "", publishableApiKey= "",sdkKey= "", packageName = this.packageName) + private fun retrieveProducts(productIdList: ArrayList) { + CBPurchase.retrieveProducts( + this, + productIdList, + object : CBCallback.ListProductsCallback> { + override fun onSuccess(productIDs: ArrayList) { + if (productIDs.first().type == ProductType.SUBS) + validateReceipt(mContext, productIDs.first()) + else + validateNonSubscriptionReceipt(mContext, productIDs.first()) + } + override fun onError(error: CBException) { + Log.e(javaClass.simpleName, "Exception: $error") + } + }) } - private fun isInternetAvailable(context: Context): Boolean { - val conMgr = context.getSystemService(CONNECTIVITY_SERVICE) as ConnectivityManager - return conMgr.activeNetworkInfo != null + private fun validateReceipt(context: Context, product: CBProduct) { + + CBPurchase.validateReceipt( + context = context, + product = product, + customer = customer, + completionCallback = object : CBCallback.PurchaseCallback { + override fun onSuccess(result: ReceiptDetail, status: Boolean) { + // Clear the local cache once receipt validation success + val editor = sharedPreference?.edit() + editor?.clear()?.apply() + Log.i(javaClass.simpleName, "Subscription ID: ${result.subscription_id}") + Log.i(javaClass.simpleName, "Plan ID: ${result.plan_id}") + Log.i(javaClass.simpleName, "Customer ID: ${result.customer_id}") + Log.i(javaClass.simpleName, "Status: $status") + } + + override fun onError(error: CBException) { + Log.e(javaClass.simpleName, "Exception :$error") + } + }) + } + + private fun validateNonSubscriptionReceipt(context: Context, product: CBProduct) { + CBPurchase.validateReceiptForNonSubscriptions( + context = context, + product = product, + customer = customer, + productType = OneTimeProductType.CONSUMABLE, + completionCallback = object : CBCallback.OneTimePurchaseCallback { + override fun onSuccess(result: NonSubscription, status: Boolean) { + // Clear the local cache once receipt validation success + val editor = sharedPreference?.edit() + editor?.clear()?.apply() + Log.i(javaClass.simpleName, "Subscription ID: ${result.invoiceId}") + Log.i(javaClass.simpleName, "Plan ID: ${result.chargeId}") + Log.i(javaClass.simpleName, "Customer ID: ${result.customerId}") + Log.i(javaClass.simpleName, "Status: $status") + } + + override fun onError(error: CBException) { + Log.e(javaClass.simpleName, "Exception :$error") + } + }) } } \ No newline at end of file diff --git a/app/src/main/java/com/chargebee/example/MainActivity.kt b/app/src/main/java/com/chargebee/example/MainActivity.kt index d9236e2..a16cc99 100644 --- a/app/src/main/java/com/chargebee/example/MainActivity.kt +++ b/app/src/main/java/com/chargebee/example/MainActivity.kt @@ -17,7 +17,7 @@ import com.chargebee.android.Chargebee import com.chargebee.android.billingservice.CBCallback import com.chargebee.android.billingservice.CBPurchase import com.chargebee.android.exceptions.CBException -import com.chargebee.android.exceptions.CBProductIDResult +import com.chargebee.android.exceptions.ChargebeeResult import com.chargebee.android.models.CBProduct import com.chargebee.example.adapter.ListItemsAdapter import com.chargebee.example.addon.AddonActivity @@ -38,12 +38,12 @@ import kotlinx.coroutines.launch class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { private var mItemsRecyclerView: RecyclerView? = null - private var list = arrayListOf() + private var list = arrayListOf() var listItemsAdapter: ListItemsAdapter? = null var featureList = mutableListOf() var mContext: Context? = null private val gson = Gson() - private var mBillingViewModel : BillingViewModel? = null + private var mBillingViewModel: BillingViewModel? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -73,9 +73,18 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { Log.i(javaClass.simpleName, "Google play product identifiers: $it") alertListProductId(it) } + + this.mBillingViewModel!!.restorePurchaseResult.observeForever { + hideProgressDialog() + if (it.isNotEmpty()) { + alertSuccess("${it.size} purchases restored successfully") + } else { + alertSuccess("Purchases not found to restore") + } + } } - private fun setListAdapter(){ + private fun setListAdapter() { featureList = CBMenu.values().toMutableList() listItemsAdapter = ListItemsAdapter(featureList, this) val layoutManager: RecyclerView.LayoutManager = LinearLayoutManager(applicationContext) @@ -85,7 +94,7 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { } override fun onItemClick(view: View?, position: Int) { - when(CBMenu.valueOf(featureList.get(position).toString()).value){ + when (CBMenu.valueOf(featureList.get(position).toString()).value) { CBMenu.Configure.value -> { if (view != null) { onClickConfigure(view) @@ -132,8 +141,13 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { CBMenu.GetEntitlements.value -> { getSubscriptionId() } - else ->{ - Log.i(javaClass.simpleName, " Not implemented" ) + CBMenu.RestorePurchase.value -> { + mBillingViewModel?.restorePurchases(this) + } + CBMenu.ManageSubscription.value -> + Chargebee.showManageSubscriptionsSettings(context = this, productId = "chargebee.pro.mobile",packageName = this.packageName) + else -> { + Log.i(javaClass.simpleName, " Not implemented") } } } @@ -148,9 +162,9 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { val builder = AlertDialog.Builder(this) val inflater = layoutInflater val dialogLayout = inflater.inflate(R.layout.activity_configure, null) - val siteNameEditText = dialogLayout.findViewById(R.id.etv_siteName) - val apiKeyEditText = dialogLayout.findViewById(R.id.etv_apikey) - val sdkKeyEditText = dialogLayout.findViewById(R.id.etv_sdkkey) + val siteNameEditText = dialogLayout.findViewById(R.id.etv_siteName) + val apiKeyEditText = dialogLayout.findViewById(R.id.etv_apikey) + val sdkKeyEditText = dialogLayout.findViewById(R.id.etv_sdkkey) builder.setView(dialogLayout) builder.setPositiveButton("Initialize") { _, i -> @@ -159,11 +173,20 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { ) && !TextUtils.isEmpty(sdkKeyEditText.text.toString()) ) Chargebee.configure( - siteNameEditText.text.toString(), - apiKeyEditText.text.toString(), - true, - sdkKeyEditText.text.toString(), this.packageName - ) + site = siteNameEditText.text.toString(), + publishableApiKey = apiKeyEditText.text.toString(), + sdkKey = sdkKeyEditText.text.toString(), + packageName = this.packageName + ) { + when (it) { + is ChargebeeResult.Success -> { + Log.i(javaClass.simpleName, "Configured") + } + is ChargebeeResult.Error -> { + Log.e(javaClass.simpleName, " Failed") + } + } + } } builder.show() } @@ -182,7 +205,8 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { } dialog.show() } - private fun getProductIdList(productIdList: ArrayList){ + + private fun getProductIdList(productIdList: ArrayList) { CBPurchase.retrieveProducts( this, productIdList, @@ -190,12 +214,13 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { override fun onSuccess(productIDs: ArrayList) { CoroutineScope(Dispatchers.Main).launch { if (productIDs.size > 0) { - launchProductDetailsScreen(gson.toJson(productIDs)) + launchProductDetailsScreen(gson.toJson(productIDs)) } else { alertSuccess("Items not available to buy") } } } + override fun onError(error: CBException) { Log.e(javaClass.simpleName, "Error: ${error.message}") showDialog(getCBError(error)) @@ -203,7 +228,6 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { }) } - private fun alertListProductId(list: Array) { val builder = AlertDialog.Builder(this) builder.setTitle("Chargebee Product IDs") @@ -215,7 +239,7 @@ class MainActivity : BaseActivity(), ListItemsAdapter.ItemClickListener { javaClass.simpleName, " Item clicked :" + list[which] + " position :" + which ) - val productIdList = ArrayList() + val productIdList = ArrayList() productIdList.add(list[which].trim()) getProductIdList(productIdList) } diff --git a/app/src/main/java/com/chargebee/example/adapter/ProductListAdapter.java b/app/src/main/java/com/chargebee/example/adapter/ProductListAdapter.java index 4bba7c2..95da11e 100644 --- a/app/src/main/java/com/chargebee/example/adapter/ProductListAdapter.java +++ b/app/src/main/java/com/chargebee/example/adapter/ProductListAdapter.java @@ -7,19 +7,26 @@ import android.widget.TextView; import androidx.recyclerview.widget.RecyclerView; +import com.chargebee.android.billingservice.ProductType; import com.chargebee.android.models.CBProduct; +import com.chargebee.android.models.PricingPhase; +import com.chargebee.android.models.SubscriptionOffer; import com.chargebee.example.R; + +import java.util.Arrays; import java.util.List; +import java.util.stream.Collectors; public class ProductListAdapter extends RecyclerView.Adapter { - private List mProductsList; + private List purchaseProducts; private ProductListAdapter.ProductClickListener mClickListener; private Context mContext = null; + private PurchaseProduct selectedProduct = null; - public ProductListAdapter(Context context, List mProductsList, ProductClickListener mClickListener) { + public ProductListAdapter(Context context, List purchaseProducts, ProductClickListener mClickListener) { mContext = context; - this.mProductsList = mProductsList; + this.purchaseProducts = purchaseProducts; this.mClickListener = mClickListener; } @@ -31,10 +38,12 @@ public ProductListAdapter.ViewHolder onCreateViewHolder(ViewGroup parent, int vi @Override public void onBindViewHolder(ProductListAdapter.ViewHolder holder, int position) { - CBProduct products = mProductsList.get(position); - holder.mTextViewTitle.setText(products.getProductId()); - holder.mTextViewPrice.setText(products.getProductPrice()); - if (products.getSubStatus()) { + PurchaseProduct purchaseProduct = purchaseProducts.get(position); + holder.mTextViewTitle.setText(purchaseProduct.getProductId() + " "+ purchaseProduct.getBasePlanId()); + holder.mTextViewPrice.setText(purchaseProduct.getPrice()); + boolean isSubscriptionProductSelected = selectedProduct != null && selectedProduct.getCbProduct().getType().equals(ProductType.SUBS) && selectedProduct.getOfferToken().equals(purchaseProduct.getOfferToken()); + boolean isOtpProductSelected = selectedProduct != null && selectedProduct.getCbProduct().getType().equals(ProductType.INAPP) && selectedProduct.getProductId().equals(purchaseProduct.getProductId()); + if (isSubscriptionProductSelected || isOtpProductSelected) { holder.mTextViewSubscribe.setText(R.string.status_subscribed); holder.mTextViewSubscribe.setTextColor(mContext.getResources().getColor(R.color.success_green)); }else { @@ -46,7 +55,7 @@ public void onBindViewHolder(ProductListAdapter.ViewHolder holder, int position) @Override public int getItemCount() { - return mProductsList.size(); + return purchaseProducts.size(); } public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { @@ -63,6 +72,7 @@ public class ViewHolder extends RecyclerView.ViewHolder implements View.OnClickL @Override public void onClick(View view) { if (mClickListener != null) { + selectedProduct = purchaseProducts.get(getAdapterPosition()); mClickListener.onProductClick(view, getAdapterPosition()); } } diff --git a/app/src/main/java/com/chargebee/example/adapter/PurchaseProduct.java b/app/src/main/java/com/chargebee/example/adapter/PurchaseProduct.java new file mode 100644 index 0000000..7d152b8 --- /dev/null +++ b/app/src/main/java/com/chargebee/example/adapter/PurchaseProduct.java @@ -0,0 +1,58 @@ +package com.chargebee.example.adapter; + +import com.chargebee.android.models.CBProduct; +import com.chargebee.android.models.PricingPhase; +import com.chargebee.android.models.SubscriptionOffer; + +public class PurchaseProduct { + private final String productId; + private final CBProduct cbProduct; + private final String basePlanId; + private final String offerId; + private final String offerToken; + private final String price; + + public PurchaseProduct(CBProduct cbProduct, SubscriptionOffer subscriptionOffer) { + this(cbProduct.getId(), cbProduct, subscriptionOffer.getBasePlanId(), subscriptionOffer.getOfferId(), + subscriptionOffer.getOfferToken(), subscriptionOffer.getPricingPhases().get(0).getFormattedPrice()); + } + + public PurchaseProduct(CBProduct cbProduct, PricingPhase oneTimePurchaseOffer) { + this(cbProduct.getId(), cbProduct, + null, null, + null, oneTimePurchaseOffer.getFormattedPrice()); + } + + public PurchaseProduct(String id, CBProduct cbProduct, String basePlanId, String offerId, String offerToken, String formattedPrice) { + this.productId = id; + this.cbProduct = cbProduct; + this.basePlanId = basePlanId; + this.offerId = offerId; + this.offerToken = offerToken; + this.price = formattedPrice; + } + + public String getProductId() { + return productId; + } + + public String getBasePlanId() { + return basePlanId; + } + + public String getOfferId() { + return offerId; + } + + public String getOfferToken() { + return offerToken; + } + + public String getPrice() { + return price; + } + + public CBProduct getCbProduct() { + return cbProduct; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/chargebee/example/billing/BillingActivity.java b/app/src/main/java/com/chargebee/example/billing/BillingActivity.java index 7ef409c..231ffa4 100644 --- a/app/src/main/java/com/chargebee/example/billing/BillingActivity.java +++ b/app/src/main/java/com/chargebee/example/billing/BillingActivity.java @@ -1,30 +1,40 @@ package com.chargebee.example.billing; +import static com.chargebee.example.util.Constants.PRODUCTS_LIST_KEY; + import android.app.Dialog; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.EditText; + import androidx.recyclerview.widget.DefaultItemAnimator; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; import com.chargebee.android.ProgressBarListener; +import com.chargebee.android.billingservice.OneTimeProductType; +import com.chargebee.android.billingservice.ProductType; import com.chargebee.android.models.CBProduct; +import com.chargebee.android.models.PurchaseProductParams; import com.chargebee.android.network.CBCustomer; import com.chargebee.example.BaseActivity; import com.chargebee.example.R; import com.chargebee.example.adapter.ProductListAdapter; +import com.chargebee.example.adapter.PurchaseProduct; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; + import java.lang.reflect.Type; import java.util.ArrayList; -import static com.chargebee.example.util.Constants.PRODUCTS_LIST_KEY; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; public class BillingActivity extends BaseActivity implements ProductListAdapter.ProductClickListener, ProgressBarListener { - private ArrayList productList = null; + private List purchaseProducts = null; private ProductListAdapter productListAdapter = null; private LinearLayoutManager linearLayoutManager; private RecyclerView mItemsRecyclerView = null; @@ -32,6 +42,7 @@ public class BillingActivity extends BaseActivity implements ProductListAdapter. private static final String TAG = "BillingActivity"; private int position = 0; CBCustomer cbCustomer; + private EditText inputProductType; @Override protected void onCreate(Bundle savedInstanceState) { @@ -46,10 +57,14 @@ protected void onCreate(Bundle savedInstanceState) { if(productDetails != null) { Gson gson = new Gson(); Type listType = new TypeToken>() {}.getType(); - productList = gson.fromJson(productDetails, listType); + List productList = gson.fromJson(productDetails, listType); + this.purchaseProducts = productList.stream() + .map(x -> toList(x)) + .flatMap(List::stream) + .collect(Collectors.toList()); } - productListAdapter = new ProductListAdapter(this,productList, this); + productListAdapter = new ProductListAdapter(this, purchaseProducts, this); linearLayoutManager = new LinearLayoutManager(this); mItemsRecyclerView.setLayoutManager(linearLayoutManager); mItemsRecyclerView.setItemAnimator(new DefaultItemAnimator()); @@ -125,35 +140,62 @@ private void getCustomerID() { EditText inputFirstName = dialog.findViewById(R.id.firstNameText); EditText inputLastName = dialog.findViewById(R.id.lastNameText); EditText inputEmail = dialog.findViewById(R.id.emailText); + inputProductType = dialog.findViewById(R.id.productTypeText); + if (isOneTimeProduct()) inputProductType.setVisibility(View.VISIBLE); + else inputProductType.setVisibility(View.GONE); Button dialogButton = dialog.findViewById(R.id.btn_ok); dialogButton.setText("Ok"); - dialogButton.setOnClickListener(new View.OnClickListener() { - @Override - public void onClick(View v) { - showProgressDialog(); - String customerId = input.getText().toString(); - String firstName = inputFirstName.getText().toString(); - String lastName = inputLastName.getText().toString(); - String email = inputEmail.getText().toString(); - cbCustomer = new CBCustomer(customerId,firstName,lastName,email); - purchaseProduct(customerId); - //purchaseProduct(); + dialogButton.setOnClickListener(view -> { + String customerId = input.getText().toString(); + String firstName = inputFirstName.getText().toString(); + String lastName = inputLastName.getText().toString(); + String email = inputEmail.getText().toString(); + String productType = inputProductType.getText().toString(); + cbCustomer = new CBCustomer(customerId,firstName,lastName,email); + if (isOneTimeProduct()){ + if (checkProductTypeFiled()) { + if (productType.trim().equalsIgnoreCase(OneTimeProductType.CONSUMABLE.getValue())) { + purchaseNonSubscriptionProduct(OneTimeProductType.CONSUMABLE); + } else if (productType.trim().equalsIgnoreCase(OneTimeProductType.NON_CONSUMABLE.getValue())) { + purchaseNonSubscriptionProduct(OneTimeProductType.NON_CONSUMABLE); + } + dialog.dismiss(); + } + } else { + purchaseProduct(); dialog.dismiss(); } }); dialog.show(); } - private void purchaseProduct(String customerId){ - this.billingViewModel.purchaseProduct(productList.get(position), customerId); + private boolean checkProductTypeFiled(){ + if (inputProductType.getText().toString().length() == 0) { + inputProductType.setError("This field is required"); + return false; + } + return true; } - private void purchaseProduct(){ - this.billingViewModel.purchaseProduct(productList.get(position), cbCustomer); + + private boolean isOneTimeProduct(){ + return purchaseProducts.get(position).getCbProduct().getType() == ProductType.INAPP; + } + + private void purchaseProduct() { + showProgressDialog(); + PurchaseProduct selectedPurchaseProduct = purchaseProducts.get(position); + PurchaseProductParams purchaseParams = new PurchaseProductParams(selectedPurchaseProduct.getCbProduct(), selectedPurchaseProduct.getOfferToken()); + this.billingViewModel.purchaseProduct(this, purchaseParams, cbCustomer); + } + + private void purchaseNonSubscriptionProduct(OneTimeProductType productType) { + showProgressDialog(); + CBProduct selectedProduct = purchaseProducts.get(position).getCbProduct(); + this.billingViewModel.purchaseNonSubscriptionProduct(this, selectedProduct, cbCustomer, productType); } private void updateSubscribeStatus(){ - productList.get(position).setSubStatus(true); productListAdapter.notifyDataSetChanged(); } @@ -167,4 +209,12 @@ public void onShowProgressBar() { public void onHideProgressBar() { hideProgressDialog(); } + private List toList(CBProduct cbProduct) { + if(cbProduct.getType() == ProductType.SUBS) { + return cbProduct.getSubscriptionOffers().stream() + .map(x -> new PurchaseProduct(cbProduct, x)).collect(Collectors.toList()); + } else { + return Arrays.asList(new PurchaseProduct(cbProduct, cbProduct.getOneTimePurchaseOffer())); + } + } } diff --git a/app/src/main/java/com/chargebee/example/billing/BillingViewModel.kt b/app/src/main/java/com/chargebee/example/billing/BillingViewModel.kt index 39164fc..1fdf2ee 100644 --- a/app/src/main/java/com/chargebee/example/billing/BillingViewModel.kt +++ b/app/src/main/java/com/chargebee/example/billing/BillingViewModel.kt @@ -1,19 +1,18 @@ package com.chargebee.example.billing +import android.content.Context +import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.chargebee.android.Chargebee -import com.chargebee.android.ErrorDetail -import com.chargebee.android.billingservice.CBCallback -import com.chargebee.android.billingservice.CBPurchase +import com.chargebee.android.billingservice.* import com.chargebee.android.exceptions.CBException import com.chargebee.android.exceptions.CBProductIDResult import com.chargebee.android.exceptions.ChargebeeResult import com.chargebee.android.models.* import com.chargebee.android.network.CBCustomer import com.chargebee.android.network.ReceiptDetail -import com.google.gson.Gson class BillingViewModel : ViewModel() { @@ -26,10 +25,13 @@ class BillingViewModel : ViewModel() { var error: MutableLiveData = MutableLiveData() var entitlementsResult: MutableLiveData = MutableLiveData() private var subscriptionId: String = "" + private lateinit var sharedPreference : SharedPreferences + var restorePurchaseResult: MutableLiveData> = MutableLiveData() - fun purchaseProduct(product: CBProduct, customer: CBCustomer) { - - CBPurchase.purchaseProduct(product, customer, object : CBCallback.PurchaseCallback{ + fun purchaseProduct(context: Context, purchaseProductParams: PurchaseProductParams, customer: CBCustomer) { + // Cache the product id in sharedPreferences and retry validating the receipt if in case server is not responding or no internet connection. + sharedPreference = context.getSharedPreferences("PREFERENCE_NAME",Context.MODE_PRIVATE) + CBPurchase.purchaseProduct(purchaseProductParams = purchaseProductParams, customer = customer, object : CBCallback.PurchaseCallback{ override fun onSuccess(result: ReceiptDetail, status:Boolean) { Log.i(TAG, "Subscription ID: ${result.subscription_id}") Log.i(TAG, "Plan ID: ${result.plan_id}") @@ -37,33 +39,53 @@ class BillingViewModel : ViewModel() { } override fun onError(error: CBException) { try { - cbException.postValue(error) - }catch (exp: Exception){ + // Handled server not responding and offline + if (error.httpStatusCode!! in 500..599) { + storeInLocal(purchaseProductParams.product.id) + validateReceipt(context = context, product = purchaseProductParams.product) + } else { + cbException.postValue(error) + } + } catch (exp: Exception) { Log.i(TAG, "Exception :${exp.message}") } } }) } - fun purchaseProduct(product: CBProduct, customerId: String) { - CBPurchase.purchaseProduct(product, customerId, object : CBCallback.PurchaseCallback{ - override fun onSuccess(result: ReceiptDetail, status:Boolean) { - Log.i(TAG, "Subscription ID: ${result.subscription_id}") - Log.i(TAG, "Plan ID: ${result.plan_id}") - productPurchaseResult.postValue(status) - } - override fun onError(error: CBException) { - try { - cbException.postValue(error) - }catch (exp: Exception){ - Log.i(TAG, "Exception :${exp.message}") + private fun validateReceipt(context: Context, product: CBProduct) { + val customer = CBCustomer( + id = "sync_receipt_android", + firstName = "Test", + lastName = "Purchase", + email = "testreceipt@gmail.com" + ) + CBPurchase.validateReceipt( + context = context, + product = product, + customer = customer, + completionCallback = object : CBCallback.PurchaseCallback { + override fun onSuccess(result: ReceiptDetail, status: Boolean) { + Log.i(TAG, "Subscription ID: ${result.subscription_id}") + Log.i(TAG, "Plan ID: ${result.plan_id}") + // Clear the local cache once receipt validation success + val editor = sharedPreference.edit() + editor.clear().apply() + productPurchaseResult.postValue(status) } - } - }) + + override fun onError(error: CBException) { + try { + cbException.postValue(error) + } catch (exp: Exception) { + Log.i(TAG, "Exception :${exp.message}") + } + } + }) } fun retrieveProductIdentifers(queryParam: Array){ - CBPurchase.retrieveProductIdentifers(queryParam) { + CBPurchase.retrieveProductIdentifiers(queryParam) { when (it) { is CBProductIDResult.ProductIds -> { Log.i(TAG, "List of Product Identifiers: $it") @@ -131,5 +153,88 @@ class BillingViewModel : ViewModel() { } } } + private fun storeInLocal(productId: String){ + val editor = sharedPreference.edit() + editor.putString("productId", productId) + editor.apply() + } + + fun purchaseNonSubscriptionProduct(context: Context,product: CBProduct, customer: CBCustomer, productType: OneTimeProductType) { + // Cache the product id in sharedPreferences and retry validating the receipt if in case server is not responding or no internet connection. + sharedPreference = context.getSharedPreferences("PREFERENCE_NAME",Context.MODE_PRIVATE) + CBPurchase.purchaseNonSubscriptionProduct( + product, customer, + productType, object : CBCallback.OneTimePurchaseCallback { + override fun onSuccess(result: NonSubscription, status:Boolean) { + Log.i(TAG, "invoice ID: ${result.invoiceId}") + Log.i(TAG, "charge ID: ${result.chargeId}") + productPurchaseResult.postValue(status) + } + override fun onError(error: CBException) { + try { + // Handled server not responding and offline + if (error.httpStatusCode!! in 500..599) { + storeInLocal(product.id) + validateNonSubscriptionReceipt(context = context, product = product, productType = productType) + } else { + cbException.postValue(error) + } + } catch (exp: Exception) { + Log.i(TAG, "Exception :${exp.message}") + } + } + }) + } + + private fun validateNonSubscriptionReceipt(context: Context, product: CBProduct, productType: OneTimeProductType) { + val customer = CBCustomer( + id = "sync_receipt_android", + firstName = "Test", + lastName = "Purchase", + email = "testreceipt@gmail.com" + ) + CBPurchase.validateReceiptForNonSubscriptions( + context = context, + product = product, + customer = customer, + productType = productType, + completionCallback = object : CBCallback.OneTimePurchaseCallback { + override fun onSuccess(result: NonSubscription, status: Boolean) { + Log.i(TAG, "Invoice ID: ${result.invoiceId}") + Log.i(TAG, "Plan ID: ${result.chargeId}") + // Clear the local cache once receipt validation success + val editor = sharedPreference.edit() + editor.clear().apply() + productPurchaseResult.postValue(status) + } + + override fun onError(error: CBException) { + try { + cbException.postValue(error) + } catch (exp: Exception) { + Log.i(TAG, "Exception :${exp.message}") + } + } + }) + } + + fun restorePurchases(context: Context, includeInActivePurchases: Boolean = false) { + val customer = CBCustomer("test-restore","","","") + CBPurchase.restorePurchases( + context = context, customer = customer, includeInActivePurchases = includeInActivePurchases, + completionCallback = object : CBCallback.RestorePurchaseCallback { + override fun onSuccess(result: List) { + result.forEach { + Log.i(javaClass.simpleName, "status : ${it.storeStatus}") + Log.i(javaClass.simpleName, "data : $it") + } + restorePurchaseResult.postValue(result) + } + + override fun onError(error: CBException) { + cbException.postValue(error) + } + }) + } } \ No newline at end of file diff --git a/app/src/main/java/com/chargebee/example/items/ItemActivity.kt b/app/src/main/java/com/chargebee/example/items/ItemActivity.kt index c015368..afb345f 100644 --- a/app/src/main/java/com/chargebee/example/items/ItemActivity.kt +++ b/app/src/main/java/com/chargebee/example/items/ItemActivity.kt @@ -6,10 +6,8 @@ import android.widget.Button import android.widget.EditText import android.widget.TextView import androidx.lifecycle.Observer -import com.chargebee.android.ErrorDetail import com.chargebee.example.BaseActivity import com.chargebee.example.R -import com.google.gson.Gson class ItemActivity: BaseActivity() { diff --git a/app/src/main/java/com/chargebee/example/items/ItemsActivity.kt b/app/src/main/java/com/chargebee/example/items/ItemsActivity.kt index 921588e..9f1eb21 100644 --- a/app/src/main/java/com/chargebee/example/items/ItemsActivity.kt +++ b/app/src/main/java/com/chargebee/example/items/ItemsActivity.kt @@ -1,7 +1,6 @@ package com.chargebee.example.items import android.os.Bundle -import android.util.Log import android.view.View import android.widget.TextView import androidx.lifecycle.Observer @@ -9,11 +8,9 @@ import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.chargebee.android.Chargebee -import com.chargebee.android.ErrorDetail import com.chargebee.example.BaseActivity import com.chargebee.example.R import com.chargebee.example.adapter.ItemsAdapter -import com.google.gson.Gson class ItemsActivity : BaseActivity(), ItemsAdapter.ItemClickListener { diff --git a/app/src/main/java/com/chargebee/example/plan/PlanInJavaActivity.java b/app/src/main/java/com/chargebee/example/plan/PlanInJavaActivity.java index c53cefe..a211878 100644 --- a/app/src/main/java/com/chargebee/example/plan/PlanInJavaActivity.java +++ b/app/src/main/java/com/chargebee/example/plan/PlanInJavaActivity.java @@ -6,12 +6,8 @@ import android.widget.EditText; import android.widget.TextView; -import com.chargebee.android.ErrorDetail; -import com.chargebee.android.models.Plan; import com.chargebee.example.BaseActivity; import com.chargebee.example.R; -import com.google.gson.Gson; -import com.google.gson.JsonObject; public class PlanInJavaActivity extends BaseActivity { diff --git a/app/src/main/java/com/chargebee/example/plan/PlansActivity.kt b/app/src/main/java/com/chargebee/example/plan/PlansActivity.kt index 4d020bb..7e6890c 100644 --- a/app/src/main/java/com/chargebee/example/plan/PlansActivity.kt +++ b/app/src/main/java/com/chargebee/example/plan/PlansActivity.kt @@ -8,12 +8,9 @@ import androidx.lifecycle.Observer import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView -import com.chargebee.android.Chargebee -import com.chargebee.android.ErrorDetail import com.chargebee.example.BaseActivity import com.chargebee.example.R import com.chargebee.example.adapter.ItemsAdapter -import com.google.gson.Gson class PlansActivity : BaseActivity(), ItemsAdapter.ItemClickListener { diff --git a/app/src/main/java/com/chargebee/example/subscription/SubscriptionActivity.kt b/app/src/main/java/com/chargebee/example/subscription/SubscriptionActivity.kt index a8bf45f..f9b1dbb 100644 --- a/app/src/main/java/com/chargebee/example/subscription/SubscriptionActivity.kt +++ b/app/src/main/java/com/chargebee/example/subscription/SubscriptionActivity.kt @@ -3,7 +3,6 @@ package com.chargebee.example.subscription import android.os.Bundle import android.util.Log import android.widget.Button -import com.chargebee.android.Chargebee import com.chargebee.example.BaseActivity import com.chargebee.example.R import com.chargebee.example.billing.BillingViewModel @@ -39,7 +38,7 @@ class SubscriptionActivity : BaseActivity() { Log.i(javaClass.simpleName, "Subscriptions by using queryParams: $it") if(it?.size!! >0) { val subscriptionStatus = - it?.get(0).cb_subscription.status + "\nPlan Price : " + it?.get(0)?.cb_subscription?.plan_amount; + (it?.get(0)?.cb_subscription?.status ?: "") + "\nPlan Price : " + it?.get(0)?.cb_subscription?.plan_amount; alertSuccess(subscriptionStatus) }else{ alertSuccess("Subscriptions not found in Chargebee System") diff --git a/app/src/main/java/com/chargebee/example/token/TokenViewModel.kt b/app/src/main/java/com/chargebee/example/token/TokenViewModel.kt index 3847ac1..525f918 100644 --- a/app/src/main/java/com/chargebee/example/token/TokenViewModel.kt +++ b/app/src/main/java/com/chargebee/example/token/TokenViewModel.kt @@ -4,7 +4,6 @@ import android.util.Log import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import com.chargebee.android.Chargebee -import com.chargebee.android.models.Token import com.chargebee.android.exceptions.InvalidRequestException import com.chargebee.android.exceptions.OperationFailedException import com.chargebee.android.exceptions.PaymentException diff --git a/app/src/main/java/com/chargebee/example/token/TokenizeActivity.kt b/app/src/main/java/com/chargebee/example/token/TokenizeActivity.kt index 0e6a4b1..033de23 100644 --- a/app/src/main/java/com/chargebee/example/token/TokenizeActivity.kt +++ b/app/src/main/java/com/chargebee/example/token/TokenizeActivity.kt @@ -5,7 +5,6 @@ import android.util.Log import android.widget.Button import android.widget.EditText import android.widget.TextView -import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Observer import com.chargebee.android.models.Card import com.chargebee.android.models.PaymentDetail diff --git a/app/src/main/java/com/chargebee/example/util/CBMenu.kt b/app/src/main/java/com/chargebee/example/util/CBMenu.kt index 93d52ac..777dc73 100644 --- a/app/src/main/java/com/chargebee/example/util/CBMenu.kt +++ b/app/src/main/java/com/chargebee/example/util/CBMenu.kt @@ -12,6 +12,8 @@ enum class CBMenu(val value: String) { GetProducts("Get Products"), SubsStatus("Get Subscription Status"), SubsList("Get Subscriptions List"), - GetEntitlements("Get Entitlements") + GetEntitlements("Get Entitlements"), + RestorePurchase("Restore Purchase"), + ManageSubscription("Manage Subscriptions") } diff --git a/app/src/main/java/com/chargebee/example/util/NetworkUtil.kt b/app/src/main/java/com/chargebee/example/util/NetworkUtil.kt new file mode 100644 index 0000000..911fa3e --- /dev/null +++ b/app/src/main/java/com/chargebee/example/util/NetworkUtil.kt @@ -0,0 +1,47 @@ +package com.chargebee.example.util + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.util.Log + +class NetworkUtil(context: Context, callback: NetworkListener) : + ConnectivityManager.NetworkCallback() { + private var mNetworkRequest: NetworkRequest? = null + private var mConnectivityManager: ConnectivityManager? = null + private var callback: NetworkListener + + interface NetworkListener { + fun onNetworkConnectionAvailable() + fun onNetworkConnectionLost() + } + + init { + mNetworkRequest = NetworkRequest.Builder() + .addTransportType(NetworkCapabilities.TRANSPORT_WIFI) + .build() + mConnectivityManager = + context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + this.callback = callback + } + + override fun onAvailable(network: Network) { + super.onAvailable(network) + Log.i(javaClass.simpleName, "Connected to network"); + callback.onNetworkConnectionAvailable() + + } + + override fun onLost(network: Network) { + super.onLost(network) + Log.i(javaClass.simpleName, "Lost network connection"); + callback.onNetworkConnectionLost() + } + + fun registerCallbackEvents() { + Log.i(javaClass.simpleName, "Register callback events to trigger network connectivity") + mConnectivityManager!!.registerNetworkCallback(mNetworkRequest!!, this) + } +} \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 43cdfa6..931cca5 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -22,7 +22,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginTop="10dp" - android:text="Chargebee Examples" + android:text="Chargebee Examples - V1.0" android:textSize="24sp" android:textStyle="bold" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/dialog_customer_layout.xml b/app/src/main/res/layout/dialog_customer_layout.xml index 69acf0a..cf511dd 100644 --- a/app/src/main/res/layout/dialog_customer_layout.xml +++ b/app/src/main/res/layout/dialog_customer_layout.xml @@ -74,6 +74,25 @@ android:hint="Email" android:inputType="textPersonName" /> + + + + app:layout_constraintTop_toBottomOf="@+id/productTypeInputLayout" /> \ 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 6b41dcc..5338d5c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -4,6 +4,6 @@ Chargebee Success! The token is : Ok - Subscribe - Subscribed + Buy + Purchased \ No newline at end of file diff --git a/build.gradle b/build.gradle index d866a33..129bae1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,13 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { - ext.kotlin_version = "1.3.72" + ext.kotlin_version = "1.8.20" ext.assertj_version = '3.16.1' repositories { google() jcenter() } dependencies { - classpath "com.android.tools.build:gradle:4.0.0" + classpath 'com.android.tools.build:gradle:4.2.2' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" // NOTE: Do not place your application dependencies here; they belong diff --git a/chargebee/build.gradle b/chargebee/build.gradle index 1e520f8..62c4ae4 100644 --- a/chargebee/build.gradle +++ b/chargebee/build.gradle @@ -1,15 +1,14 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' -apply plugin: 'kotlin-android-extensions' android { - compileSdkVersion 30 + compileSdkVersion 31 defaultConfig { minSdkVersion 21 - targetSdkVersion 30 + targetSdkVersion 31 versionCode 1 - versionName "1.0.16" + versionName "2.0.0-beta-4" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -39,10 +38,10 @@ dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7" testImplementation 'junit:junit:4.12' androidTestImplementation 'androidx.test.ext:junit:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' // Google play billing library - implementation 'com.android.billingclient:billing-ktx:4.0.0' + implementation 'com.android.billingclient:billing-ktx:6.2.1' // AssertJ testImplementation "org.assertj:assertj-core:$assertj_version" @@ -53,7 +52,9 @@ dependencies { // Mockito testImplementation 'org.mockito:mockito-core:2.23.0' - testImplementation 'androidx.test:core:1.2.0' + testImplementation 'org.json:json:20140107' + + testImplementation 'androidx.test:core:1.4.0' testImplementation 'androidx.test.ext:junit:1.1.1' } diff --git a/chargebee/consumer-rules.pro b/chargebee/consumer-rules.pro index e69de29..83dc4b3 100644 --- a/chargebee/consumer-rules.pro +++ b/chargebee/consumer-rules.pro @@ -0,0 +1,2 @@ +# keep the classes when optimizing the code +-keep class com.chargebee.android.** { *;} \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/CBResult.kt b/chargebee/src/main/java/com/chargebee/android/CBResult.kt index 6299dc8..1e8e8df 100644 --- a/chargebee/src/main/java/com/chargebee/android/CBResult.kt +++ b/chargebee/src/main/java/com/chargebee/android/CBResult.kt @@ -74,14 +74,14 @@ internal fun responseFromServer(response: Response): ChargebeeResult ) ) } - 401 -> { + in 400..499 -> { return ChargebeeResult.Error( exp = CBException( error = ErrorDetail(response.errorBody()?.string(), httpStatusCode = response.code()) ) ) } - 400, 500 -> { + in 500..599 -> { return ChargebeeResult.Error( exp = CBException( error = ErrorDetail(response.errorBody()?.string(), httpStatusCode = response.code()) diff --git a/chargebee/src/main/java/com/chargebee/android/Chargebee.kt b/chargebee/src/main/java/com/chargebee/android/Chargebee.kt index 67ff6cb..8fa3dc2 100644 --- a/chargebee/src/main/java/com/chargebee/android/Chargebee.kt +++ b/chargebee/src/main/java/com/chargebee/android/Chargebee.kt @@ -1,9 +1,12 @@ package com.chargebee.android +import android.content.Context +import android.content.Intent +import android.content.Intent.* +import android.net.Uri +import androidx.core.content.ContextCompat import android.text.TextUtils import android.util.Log -import com.chargebee.android.BuildConfig -import com.chargebee.android.billingservice.CBPurchase import com.chargebee.android.exceptions.* import com.chargebee.android.gateway.GatewayTokenizer import com.chargebee.android.loggers.CBLogger @@ -32,11 +35,21 @@ object Chargebee { var appName: String = "Chargebee" var environment: String = "cb_android_sdk" const val platform: String = "Android" - const val sdkVersion: String = BuildConfig.VERSION_NAME + const val sdkVersion: String = "2.0.0-beta-4" const val limit: String = "100" + private const val PLAY_STORE_SUBSCRIPTION_URL = + "https://play.google.com/store/account/subscriptions" + private const val SUBSCRIPTION_URL = + "https://play.google.com/store/account/subscriptions?sku=%s&package=%s" /* Configure the app details with chargebee system */ - fun configure(site: String, publishableApiKey: String, allowErrorLogging: Boolean = true, sdkKey: String="", packageName: String="" ) { + fun configure( + site: String, + publishableApiKey: String, + allowErrorLogging: Boolean = true, + sdkKey: String = "", + packageName: String = "" + ) { this.applicationId = packageName this.publishableApiKey = publishableApiKey this.site = site @@ -44,11 +57,11 @@ object Chargebee { this.baseUrl = "https://${site}.chargebee.com/api/" this.allowErrorLogging = allowErrorLogging this.sdkKey = sdkKey - val auth = Auth(sdkKey,applicationId,appName, channel) + val auth = Auth(sdkKey, applicationId, appName, channel) CBAuthentication.authenticate(auth) { - when(it){ - is ChargebeeResult.Success ->{ + when (it) { + is ChargebeeResult.Success -> { Log.i(javaClass.simpleName, "Environment Setup Completed") Log.i(javaClass.simpleName, " Response :${it.data}") val response = it.data as CBAuthResponse @@ -56,7 +69,7 @@ object Chargebee { this.applicationId = response.in_app_detail.app_id this.appName = response.in_app_detail.app_name } - is ChargebeeResult.Error ->{ + is ChargebeeResult.Error -> { Log.i(javaClass.simpleName, "Exception from server :${it.exp.message}") this.version = CatalogVersion.Unknown.value } @@ -106,45 +119,69 @@ object Chargebee { @Throws(InvalidRequestException::class, OperationFailedException::class) fun retrieveSubscription(subscriptionId: String, completion: (ChargebeeResult) -> Unit) { val logger = CBLogger(name = "Subscription", action = "Fetch Subscription") - ResultHandler.safeExecuter({ SubscriptionResource().retrieveSubscription(subscriptionId) }, completion, logger) + ResultHandler.safeExecuter( + { SubscriptionResource().retrieveSubscription(subscriptionId) }, + completion, + logger + ) } + /* Get the subscriptions list from chargebee system by using Customer Id */ @Throws(InvalidRequestException::class, OperationFailedException::class) - fun retrieveSubscriptions(queryParams: Map = mapOf(), completion: (ChargebeeResult) -> Unit) { - val logger = CBLogger(name = "Subscription", action = "Fetch Subscription by using CustomerId") + fun retrieveSubscriptions( + queryParams: Map = mapOf(), + completion: (ChargebeeResult) -> Unit + ) { + val logger = + CBLogger(name = "Subscription", action = "Fetch Subscription by using CustomerId") if (queryParams.isNotEmpty()) { - ResultHandler.safeExecuter( - { SubscriptionResource().retrieveSubscriptions(queryParams) }, - completion, - logger - ) - }else{ - completion(ChargebeeResult.Error( - exp = CBException( - error = ErrorDetail(message = "Array/Query Param is empty", apiErrorCode = "400", httpStatusCode = 400) + ResultHandler.safeExecuter( + { SubscriptionResource().retrieveSubscriptions(queryParams) }, + completion, + logger + ) + } else { + completion( + ChargebeeResult.Error( + exp = CBException( + error = ErrorDetail( + message = "Array/Query Param is empty", + apiErrorCode = "400", + httpStatusCode = 400 + ) + ) ) - )) + ) } } /* Get the Plan details from chargebee system */ @Throws(InvalidRequestException::class, OperationFailedException::class) - fun retrievePlan(planId: String, completion : (ChargebeeResult) -> Unit) { + fun retrievePlan(planId: String, completion: (ChargebeeResult) -> Unit) { val logger = CBLogger(name = "plan", action = "getPlan") if (TextUtils.isEmpty(planId)) - completion(ChargebeeResult.Error( - exp = CBException( - error = ErrorDetail(message = "Plan ID is empty", apiErrorCode = "400", httpStatusCode = 400) + completion( + ChargebeeResult.Error( + exp = CBException( + error = ErrorDetail( + message = "Plan ID is empty", + apiErrorCode = "400", + httpStatusCode = 400 + ) + ) ) - )) + ) else ResultHandler.safeExecuter({ PlanResource().retrievePlan(planId) }, completion, logger) } /* Get the list of Plan's from chargebee system */ @Throws(InvalidRequestException::class, OperationFailedException::class) - fun retrieveAllPlans(params: Array = arrayOf(), completion : (ChargebeeResult) -> Unit) { + fun retrieveAllPlans( + params: Array = arrayOf(), + completion: (ChargebeeResult) -> Unit + ) { val logger = CBLogger(name = "plans", action = "getAllPlan") if (params.isNotEmpty()) { ResultHandler.safeExecuter( @@ -190,14 +227,20 @@ object Chargebee { /* Get the Item details from chargebee system */ @Throws(InvalidRequestException::class, OperationFailedException::class) - fun retrieveItem(itemId: String, completion : (ChargebeeResult) -> Unit) { + fun retrieveItem(itemId: String, completion: (ChargebeeResult) -> Unit) { val logger = CBLogger(name = "item", action = "getItem") if (TextUtils.isEmpty(itemId)) - completion(ChargebeeResult.Error( - exp = CBException( - error = ErrorDetail(message = "Item ID is empty", apiErrorCode = "400", httpStatusCode = 400) + completion( + ChargebeeResult.Error( + exp = CBException( + error = ErrorDetail( + message = "Item ID is empty", + apiErrorCode = "400", + httpStatusCode = 400 + ) + ) ) - )) + ) else ResultHandler.safeExecuter({ ItemsResource().retrieveItem(itemId) }, completion, logger) } @@ -206,7 +249,11 @@ object Chargebee { @Throws(InvalidRequestException::class, OperationFailedException::class) fun retrieveEntitlements(subscriptionId: String, completion: (ChargebeeResult) -> Unit) { val logger = CBLogger(name = "Entitlements", action = "retrieve_entitlements") - ResultHandler.safeExecuter({ EntitlementsResource().retrieveEntitlements(subscriptionId) }, completion, logger) + ResultHandler.safeExecuter( + { EntitlementsResource().retrieveEntitlements(subscriptionId) }, + completion, + logger + ) } @Throws(InvalidRequestException::class, OperationFailedException::class) @@ -215,7 +262,11 @@ object Chargebee { ResultHandler.safeExecute({ AddonResource().retrieve(addonId) }, handler, logger) } - @Throws(InvalidRequestException::class, OperationFailedException::class, PaymentException::class) + @Throws( + InvalidRequestException::class, + OperationFailedException::class, + PaymentException::class + ) fun createTempToken(detail: PaymentDetail, completion: (CBResult) -> Unit) { val logger = CBLogger(name = "cb_temp_token", action = "create_temp_token") ResultHandler.safeExecute({ @@ -230,4 +281,30 @@ object Chargebee { Success(cbTempToken) }, completion, logger) } + + /** + * This method will be used to show the Manage Subscriptions Settings in your App, + * + * @param [context] Current activity context + * @param [productId] Optional. Product Identifier. + * @param [packageName] Optional. Application Id. + */ + fun showManageSubscriptionsSettings( + context: Context, + productId: String? = null, + packageName: String? = null + ) { + val uriString = if (productId == null && packageName == null) { + PLAY_STORE_SUBSCRIPTION_URL + } else { + String.format( + SUBSCRIPTION_URL, + productId, packageName + ); + } + val intent = Intent(Intent.ACTION_VIEW) + intent.data = Uri.parse(uriString) + intent.flags = FLAG_ACTIVITY_NEW_TASK + ContextCompat.startActivity(context, intent, null) + } } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/billingservice/BillingClientManager.kt b/chargebee/src/main/java/com/chargebee/android/billingservice/BillingClientManager.kt index 2ddb7e9..b2c6d35 100644 --- a/chargebee/src/main/java/com/chargebee/android/billingservice/BillingClientManager.kt +++ b/chargebee/src/main/java/com/chargebee/android/billingservice/BillingClientManager.kt @@ -4,186 +4,282 @@ import android.app.Activity import android.content.Context import android.os.Handler import android.os.Looper -import android.text.TextUtils import android.util.Log import com.android.billingclient.api.* import com.android.billingclient.api.BillingClient.BillingResponseCode.* import com.chargebee.android.ErrorDetail +import com.chargebee.android.billingservice.BillingErrorCode.Companion.throwCBException import com.chargebee.android.exceptions.CBException import com.chargebee.android.exceptions.ChargebeeResult +import com.chargebee.android.models.CBNonSubscriptionResponse import com.chargebee.android.models.CBProduct +import com.chargebee.android.models.PricingPhase +import com.chargebee.android.models.PurchaseProductParams +import com.chargebee.android.models.PurchaseTransaction +import com.chargebee.android.models.SubscriptionOffer import com.chargebee.android.network.CBReceiptResponse -import java.util.* +import com.chargebee.android.restore.CBRestorePurchaseManager +import java.util.concurrent.ConcurrentLinkedQueue -class BillingClientManager constructor( - context: Context, skuType: String, - skuList: ArrayList, callBack: CBCallback.ListProductsCallback> -) : BillingClientStateListener, PurchasesUpdatedListener { +class BillingClientManager(context: Context) : PurchasesUpdatedListener { private val CONNECT_TIMER_START_MILLISECONDS = 1L * 1000L - lateinit var billingClient: BillingClient - var mContext : Context? = null + private var billingClient: BillingClient? = null + var mContext: Context? = context private val handler = Handler(Looper.getMainLooper()) - private var skuType : String? = null - private var skuList = arrayListOf() - private var callBack : CBCallback.ListProductsCallback> private var purchaseCallBack: CBCallback.PurchaseCallback? = null - private val skusWithSkuDetails = arrayListOf() private val TAG = javaClass.simpleName - lateinit var product: CBProduct - + private lateinit var purchaseProductParams: PurchaseProductParams + private lateinit var restorePurchaseCallBack: CBCallback.RestorePurchaseCallback + private var oneTimePurchaseCallback: CBCallback.OneTimePurchaseCallback? = null + private val requests = ConcurrentLinkedQueue Unit, (connectionError: CBException) -> Unit>>() init { - mContext = context - this.skuList = skuList - this.skuType =skuType - this.callBack = callBack - startBillingServiceConnection() - + this.mContext = context } - /* Called to notify that the connection to the billing service was lost*/ - override fun onBillingServiceDisconnected() { - connectToBillingService() + internal fun retrieveProducts( + products: ArrayList, callBack: CBCallback.ListProductsCallback> + ) { + val productsList = ArrayList() + retrieveProducts(BillingClient.ProductType.SUBS, products, { subsProductsList -> + productsList.addAll(subsProductsList) + retrieveProducts(BillingClient.ProductType.INAPP, products, { inAppProductsList -> + productsList.addAll(inAppProductsList) + callBack.onSuccess(productsList) + }, { error -> + callBack.onError(error) + }) + }, { error -> + callBack.onError(error) + }) } - /* The listener method will be called when the billing client setup process complete */ - override fun onBillingSetupFinished(billingResult: BillingResult) { - when (billingResult.responseCode) { - BillingClient.BillingResponseCode.OK -> { - Log.i( - TAG, - "Google Billing Setup Done!" - ) - loadProductDetails(BillingClient.SkuType.SUBS, skuList, callBack) - } - BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED, - BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> { - callBack.onError(CBException(ErrorDetail(message = GPErrorCode.BillingUnavailable.errorMsg, httpStatusCode = billingResult.responseCode))) - Log.i(TAG, "onBillingSetupFinished() -> with error: ${billingResult.debugMessage}") - } - BillingClient.BillingResponseCode.SERVICE_DISCONNECTED, - BillingClient.BillingResponseCode.USER_CANCELED, - BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE, - BillingClient.BillingResponseCode.ITEM_UNAVAILABLE, - BillingClient.BillingResponseCode.ERROR, - BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED, - BillingClient.BillingResponseCode.SERVICE_TIMEOUT, - BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> { - Log.i( - TAG, - "onBillingSetupFinished() -> google billing client error: ${billingResult.debugMessage}" - ) - } - BillingClient.BillingResponseCode.DEVELOPER_ERROR -> { - Log.i( - TAG, - "onBillingSetupFinished() -> Client is already in the process of connecting to billing service" + internal fun retrieveProducts( + @BillingClient.ProductType productType: String, + products: ArrayList, response: (ArrayList) -> Unit, + errorDetail: (CBException) -> Unit + ) { + onConnected({ status -> + if (status) + loadProductDetails(productType, products, { + response(it) + }, { + errorDetail(it) + }) + else + errorDetail( + connectionError ) + }, { error -> + errorDetail(error) + }) + } + + /* Get the SKU/Products from Play Console */ + private fun loadProductDetails( + @BillingClient.ProductType productType: String, + products: ArrayList, + response: (ArrayList) -> Unit, + errorDetail: (CBException) -> Unit + ) { + try { + val queryProductDetails = products.map { + QueryProductDetailsParams.Product.newBuilder() + .setProductId(it) + .setProductType(productType) + .build() } - else -> { - Log.i(TAG, "onBillingSetupFinished -> with error: ${billingResult.debugMessage}.") + + val productDetailsParams = QueryProductDetailsParams.newBuilder().setProductList(queryProductDetails).build() + + billingClient?.queryProductDetailsAsync( + productDetailsParams + ) { billingResult, productsDetail -> + if (billingResult.responseCode == OK && productsDetail != null) { + try { + val cbProductDetails = arrayListOf() + for (productDetail in productsDetail) { + val cbProduct = convertToCbProduct(productDetail) + cbProductDetails.add(cbProduct) + } + Log.i(TAG, "Product details :$cbProductDetails") + response(cbProductDetails) + } catch (ex: CBException) { + errorDetail( + CBException( + ErrorDetail( + message = "Error while parsing data", + httpStatusCode = billingResult.responseCode + ) + ) + ) + Log.e(TAG, "exception :" + ex.message) + } + } else { + Log.e(TAG, "Response Code :" + billingResult.responseCode) + errorDetail( + throwCBException(billingResult) + ) + } } + } catch (exp: CBException) { + Log.e(TAG, "exception :$exp.message") + errorDetail(CBException(ErrorDetail(message = "${exp.message}"))) + } + } + private fun convertToCbProduct(productDetail: ProductDetails): CBProduct { + val subscriptionOffers = subscriptionOffers(productDetail.subscriptionOfferDetails) + val oneTimePurchaseOffer = oneTimePurchaseOffer(productDetail.oneTimePurchaseOfferDetails) + return CBProduct( + productDetail.productId, + productDetail.title, + productDetail.description, + ProductType.getProductType(productDetail.productType), + subscriptionOffers, + oneTimePurchaseOffer + ) + } + + private fun oneTimePurchaseOffer(oneTimePurchaseOfferDetails: ProductDetails.OneTimePurchaseOfferDetails?): PricingPhase? { + return oneTimePurchaseOfferDetails?.let { + return PricingPhase(oneTimePurchaseOfferDetails.formattedPrice, + oneTimePurchaseOfferDetails.priceAmountMicros, + oneTimePurchaseOfferDetails.priceCurrencyCode) } } - /* Method used to configure and create a instance of billing client */ - private fun startBillingServiceConnection() { - billingClient = mContext?.let { - BillingClient.newBuilder(it) - .enablePendingPurchases() - .setListener(this).build() - }!! + private fun subscriptionOffers(subscriptionOfferDetails: List?): List? { + return subscriptionOfferDetails?.let { it.map { i -> subscriptionOffer(i) } } + } - connectToBillingService() + private fun subscriptionOffer(subscriptionOfferDetail: ProductDetails.SubscriptionOfferDetails): SubscriptionOffer { + val pricingPhases = pricingPhases(subscriptionOfferDetail.pricingPhases) + return SubscriptionOffer( + subscriptionOfferDetail.basePlanId, + subscriptionOfferDetail.offerId, + subscriptionOfferDetail.offerToken, + pricingPhases + ) } - /* Connect the billing client service */ - private fun connectToBillingService() { - if (!billingClient.isReady) { - handler.postDelayed( - { billingClient.startConnection(this@BillingClientManager) }, - CONNECT_TIMER_START_MILLISECONDS + + private fun pricingPhases(pricingPhases: ProductDetails.PricingPhases): List { + return pricingPhases.pricingPhaseList.map { + PricingPhase( + it.formattedPrice, + it.priceAmountMicros, + it.priceCurrencyCode, + it.billingPeriod, + it.billingCycleCount ) } } - /* Get the SKU/Products from Play Console */ - private fun loadProductDetails( - @BillingClient.SkuType skuType: String, - skuList: ArrayList, callBack: CBCallback.ListProductsCallback> + internal fun purchase( + purchaseProductParams: PurchaseProductParams, + purchaseCallBack: CBCallback.PurchaseCallback ) { - try { - val params = SkuDetailsParams - .newBuilder() - .setSkusList(skuList) - .setType(skuType) - .build() - - billingClient.querySkuDetailsAsync( - params - ) { billingResult, skuDetailsList -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK && skuDetailsList != null) { - try { - skusWithSkuDetails.clear() - for (skuProduct in skuDetailsList) { - val product = CBProduct( - skuProduct.sku, - skuProduct.title, - skuProduct.price, - skuProduct, - false - ) - skusWithSkuDetails.add(product) - } - Log.i(TAG, "Product details :$skusWithSkuDetails") - callBack.onSuccess(productIDs = skusWithSkuDetails) - }catch (ex: CBException){ - callBack.onError(CBException(ErrorDetail(message = "Error while parsing data", httpStatusCode = billingResult.responseCode))) - Log.e(TAG, "exception :" + ex.message) - } - }else{ - Log.e(TAG, "Response Code :" + billingResult.responseCode) - callBack.onError(CBException(ErrorDetail(message = GPErrorCode.PlayServiceUnavailable.errorMsg, httpStatusCode = billingResult.responseCode))) - } - } - }catch (exp: CBException){ - Log.e(TAG, "exception :$exp.message") - callBack.onError(CBException(ErrorDetail(message = "${exp.message}"))) - } + this.purchaseCallBack = purchaseCallBack + onConnected({ status -> + if (status) { + purchase(purchaseProductParams) + } else + purchaseCallBack.onError( + connectionError + ) + }, { error -> + purchaseCallBack.onError(error) + }) } /* Purchase the product: Initiates the billing flow for an In-app-purchase */ - fun purchase( - product: CBProduct, - purchaseCallBack: CBCallback.PurchaseCallback - ) { - this.purchaseCallBack = purchaseCallBack - this.product = product - val skuDetails = product.skuDetails - - val params = BillingFlowParams.newBuilder() - .setSkuDetails(skuDetails) - .build() - - billingClient.launchBillingFlow(mContext as Activity, params) - .takeIf { billingResult -> billingResult.responseCode != BillingClient.BillingResponseCode.OK - }?.let { billingResult -> - Log.e(TAG, "Failed to launch billing flow $billingResult") - purchaseCallBack.onError(CBException(ErrorDetail(message = GPErrorCode.LaunchBillingFlowError.errorMsg, httpStatusCode = billingResult.responseCode))) + private fun purchase(purchaseProductParams: PurchaseProductParams) { + this.purchaseProductParams = purchaseProductParams + val offerToken = purchaseProductParams.offerToken + + val queryProductDetails = arrayListOf(QueryProductDetailsParams.Product.newBuilder() + .setProductId(purchaseProductParams.product.id) + .setProductType(purchaseProductParams.product.type.value) + .build()) + + val productDetailsParams = QueryProductDetailsParams.newBuilder().setProductList(queryProductDetails).build() + + billingClient?.queryProductDetailsAsync( + productDetailsParams + ) { billingResult, productsDetail -> + if (billingResult.responseCode == OK && productsDetail != null) { + val productDetailsBuilder = BillingFlowParams.ProductDetailsParams.newBuilder() + .setProductDetails(productsDetail.first()) + offerToken?.let { productDetailsBuilder.setOfferToken(it) } + val productDetailsParamsList = + listOf(productDetailsBuilder.build()) + + val billingFlowParams = + BillingFlowParams.newBuilder() + .setProductDetailsParamsList(productDetailsParamsList) + .build() + + billingClient?.launchBillingFlow(mContext as Activity, billingFlowParams) + .takeIf { billingResult -> + billingResult?.responseCode != OK + }?.let { billingResult -> + Log.e(TAG, "Failed to launch billing flow $billingResult") + val billingError = CBException( + ErrorDetail( + message = GPErrorCode.LaunchBillingFlowError.errorMsg, + httpStatusCode = billingResult.responseCode + ) + ) + if (ProductType.SUBS == purchaseProductParams.product.type) { + purchaseCallBack?.onError( + billingError + ) + } else { + oneTimePurchaseCallback?.onError( + billingError + ) + } + } + } else { + Log.e(TAG, "Failed to fetch product :" + billingResult.responseCode) + if (ProductType.SUBS == purchaseProductParams.product.type) { + purchaseCallBack?.onError(throwCBException(billingResult)) + } else { + oneTimePurchaseCallback?.onError(throwCBException(billingResult)) + } + } + } + + } + /** + * This method will provide all the purchases associated with the current account based on the [includeInActivePurchases] flag set. + * And the associated purchases will be synced with Chargebee. + * + * @param [completionCallback] The listener will be called when restore purchase completes. + */ + internal fun restorePurchases(completionCallback: CBCallback.RestorePurchaseCallback) { + this.restorePurchaseCallBack = completionCallback + onConnected({ status -> + queryPurchaseHistoryFromStore(status) + }, { error -> + completionCallback.onError(error) + }) } /* Checks if the specified feature is supported by the Play Store */ fun isFeatureSupported(): Boolean { try { - val featureSupportedResult = billingClient.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS) - when(featureSupportedResult.responseCode){ - BillingClient.BillingResponseCode.OK -> { + val featureSupportedResult = + billingClient?.isFeatureSupported(BillingClient.FeatureType.SUBSCRIPTIONS) + when (featureSupportedResult?.responseCode) { + OK -> { return true } - BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> { + + FEATURE_NOT_SUPPORTED -> { return false } } @@ -194,12 +290,15 @@ class BillingClientManager constructor( } /* Checks if the billing client connected to the service */ - fun isBillingClientReady(): Boolean{ - return billingClient.isReady + fun isBillingClientReady(): Boolean? { + return billingClient?.isReady } /* Google Play calls this method to deliver the result of the Purchase Process/Operation */ - override fun onPurchasesUpdated(billingResult: BillingResult, purchases: MutableList?) { + override fun onPurchasesUpdated( + billingResult: BillingResult, + purchases: MutableList? + ) { when (billingResult.responseCode) { OK -> { purchases?.forEach { purchase -> @@ -207,149 +306,431 @@ class BillingClientManager constructor( Purchase.PurchaseState.PURCHASED -> { acknowledgePurchase(purchase) } + Purchase.PurchaseState.PENDING -> { - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PurchasePending.errorMsg, httpStatusCode = billingResult.responseCode))) + purchaseCallBack?.onError( + CBException( + ErrorDetail( + message = GPErrorCode.PurchasePending.errorMsg, + httpStatusCode = billingResult.responseCode + ) + ) + ) } + Purchase.PurchaseState.UNSPECIFIED_STATE -> { - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PurchaseUnspecified.errorMsg, httpStatusCode = billingResult.responseCode))) + purchaseCallBack?.onError( + CBException( + ErrorDetail( + message = GPErrorCode.PurchaseUnspecified.errorMsg, + httpStatusCode = billingResult.responseCode + ) + ) + ) } } } } - ITEM_ALREADY_OWNED -> { - Log.e(TAG, "Billing response code : ITEM_ALREADY_OWNED") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.ProductAlreadyOwned.errorMsg, httpStatusCode = billingResult.responseCode))) - } - SERVICE_DISCONNECTED -> { - connectToBillingService() - } - ITEM_UNAVAILABLE -> { - Log.e(TAG, "Billing response code : ITEM_UNAVAILABLE") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.ProductUnavailable.errorMsg, httpStatusCode = billingResult.responseCode))) - } - USER_CANCELED ->{ - Log.e(TAG, "Billing response code : USER_CANCELED ") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.CanceledPurchase.errorMsg, httpStatusCode = billingResult.responseCode))) - } - ITEM_NOT_OWNED ->{ - Log.e(TAG, "Billing response code : ITEM_NOT_OWNED ") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.ProductNotOwned.errorMsg, httpStatusCode = billingResult.responseCode))) - } - SERVICE_TIMEOUT -> { - Log.e(TAG, "Billing response code :SERVICE_TIMEOUT ") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PlayServiceTimeOut.errorMsg, httpStatusCode = billingResult.responseCode))) - } - SERVICE_UNAVAILABLE -> { - Log.e(TAG, "Billing response code: SERVICE_UNAVAILABLE") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PlayServiceUnavailable.errorMsg, httpStatusCode = billingResult.responseCode))) - } - ERROR -> { - Log.e(TAG, "Billing response code: ERROR") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.UnknownError.errorMsg, httpStatusCode = billingResult.responseCode))) - } - DEVELOPER_ERROR -> { - Log.e(TAG, "Billing response code: DEVELOPER_ERROR") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.DeveloperError.errorMsg, httpStatusCode = billingResult.responseCode))) - } - BILLING_UNAVAILABLE -> { - Log.e(TAG, "Billing response code: BILLING_UNAVAILABLE") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.BillingUnavailable.errorMsg, httpStatusCode = billingResult.responseCode))) - } - FEATURE_NOT_SUPPORTED -> { - Log.e(TAG, "Billing response code: FEATURE_NOT_SUPPORTED") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.FeatureNotSupported.errorMsg, httpStatusCode = billingResult.responseCode))) + + else -> { + if (purchaseProductParams.product.type == ProductType.SUBS) + purchaseCallBack?.onError( + throwCBException(billingResult) + ) + else + oneTimePurchaseCallback?.onError( + throwCBException(billingResult) + ) } } } /* Acknowledge the Purchases */ private fun acknowledgePurchase(purchase: Purchase) { + when (purchaseProductParams.product.type) { + ProductType.SUBS -> { + isAcknowledgedPurchase(purchase, { + validateReceipt(purchase.purchaseToken, purchaseProductParams.product) + }, { + purchaseCallBack?.onError(it) + }) + } + + ProductType.INAPP -> { + if (CBPurchase.productType == OneTimeProductType.CONSUMABLE) { + consumeAsyncPurchase(purchase.purchaseToken) + } else { + isAcknowledgedPurchase(purchase, { + validateNonSubscriptionReceipt(purchase.purchaseToken, purchaseProductParams.product) + }, { + oneTimePurchaseCallback?.onError(it) + }) + } + } + } + } + + private fun isAcknowledgedPurchase( + purchase: Purchase, + success: () -> Unit, + error: (CBException) -> Unit + ) { if (!purchase.isAcknowledged) { val params = AcknowledgePurchaseParams.newBuilder() .setPurchaseToken(purchase.purchaseToken) .build() - billingClient.acknowledgePurchase(params) { billingResult -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - try { - if (purchase.purchaseToken.isEmpty()){ - Log.e(TAG, "Receipt Not Found") - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PurchaseReceiptNotFound.errorMsg, httpStatusCode = billingResult.responseCode))) - }else { + billingClient?.acknowledgePurchase(params) { billingResult -> + when (billingResult.responseCode) { + OK -> { + if (purchase.purchaseToken.isNotEmpty()) { Log.i(TAG, "Google Purchase - success") - Log.i(TAG, "Purchase Token -${purchase.purchaseToken}") - validateReceipt(purchase.purchaseToken, product) + success() + } else { + Log.e(TAG, "Receipt Not Found") + error( + CBException( + ErrorDetail( + message = GPErrorCode.PurchaseReceiptNotFound.errorMsg, + httpStatusCode = billingResult.responseCode + ) + ) + ) } + } - } catch (ex: CBException) { - Log.e("Error", ex.toString()) - purchaseCallBack?.onError(CBException(ErrorDetail(message = ex.message))) + else -> { + error( + throwCBException(billingResult) + ) } } } } + } + + /* Consume the Purchases */ + private fun consumeAsyncPurchase(token: String) { + consumePurchase(token) { billingResult, purchaseToken -> + when (billingResult.responseCode) { + OK -> { + validateNonSubscriptionReceipt(purchaseToken, purchaseProductParams.product) + } + + else -> { + oneTimePurchaseCallback?.onError( + throwCBException(billingResult) + ) + } + } + } + } + internal fun consumePurchase( + token: String, + onConsumed: (billingResult: BillingResult, purchaseToken: String) -> Unit + ) { + onConnected({ status -> + if (status) + billingClient?.consumeAsync( + ConsumeParams.newBuilder().setPurchaseToken(token).build(), onConsumed + ) + else + oneTimePurchaseCallback?.onError( + connectionError + ) + }, { error -> + oneTimePurchaseCallback?.onError(error) + }) } /* Chargebee method called here to validate receipt */ private fun validateReceipt(purchaseToken: String, product: CBProduct) { - try{ - CBPurchase.validateReceipt(purchaseToken, product) { - when(it) { - is ChargebeeResult.Success -> { - Log.i( - TAG, - "Validate Receipt Response: ${(it.data as CBReceiptResponse).in_app_subscription}" - ) - if (it.data.in_app_subscription != null){ - val subscriptionId = (it.data).in_app_subscription.subscription_id - Log.i(TAG, "Subscription ID: $subscriptionId") - val subscriptionResult = (it.data).in_app_subscription - if (subscriptionId.isEmpty()) { - purchaseCallBack?.onSuccess(subscriptionResult, false) + try { + CBPurchase.validateReceipt(purchaseToken, product) { + when (it) { + is ChargebeeResult.Success -> { + Log.i( + TAG, + "Validate Receipt Response: ${(it.data as CBReceiptResponse).in_app_subscription}" + ) + if (it.data.in_app_subscription != null) { + val subscriptionId = (it.data).in_app_subscription.subscription_id + Log.i(TAG, "Subscription ID: $subscriptionId") + val subscriptionResult = (it.data).in_app_subscription + if (subscriptionId.isEmpty()) { + purchaseCallBack?.onSuccess(subscriptionResult, false) + } else { + purchaseCallBack?.onSuccess(subscriptionResult, true) + } } else { - purchaseCallBack?.onSuccess(subscriptionResult, true) + purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PurchaseInvalid.errorMsg))) } - }else{ - purchaseCallBack?.onError(CBException(ErrorDetail(message = GPErrorCode.PurchaseInvalid.errorMsg))) } - } - is ChargebeeResult.Error -> { - Log.e(TAG, "Exception from Server - validateReceipt() : ${it.exp.message}") - purchaseCallBack?.onError(it.exp) + + is ChargebeeResult.Error -> { + Log.e(TAG, "Exception from Server - validateReceipt() : ${it.exp.message}") + purchaseCallBack?.onError(it.exp) + } } } - } - }catch (exp: Exception){ + } catch (exp: Exception) { Log.e(TAG, "Exception from Server- validateReceipt() : ${exp.message}") purchaseCallBack?.onError(CBException(ErrorDetail(message = exp.message))) } } - fun queryAllPurchases(){ - billingClient.queryPurchasesAsync( - BillingClient.SkuType.SUBS - ) { billingResult, activeSubsList -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.i(TAG, "queryAllPurchases :$activeSubsList") - } else { - Log.i( - TAG, - "queryAllPurchases :${billingResult.debugMessage}" + private val connectionError = CBException( + ErrorDetail( + message = BillingErrorCode.SERVICE_UNAVAILABLE.message, + httpStatusCode = BillingErrorCode.SERVICE_UNAVAILABLE.code + ) + ) + + private fun itemNotOwnedException(): CBException { + return CBException(ErrorDetail( + message = BillingErrorCode.ITEM_NOT_OWNED.message, + httpStatusCode = BillingErrorCode.ITEM_NOT_OWNED.code + )) + } + + private fun queryPurchaseHistoryFromStore( + connectionStatus: Boolean + ) { + if (connectionStatus) { + queryAllSubsPurchaseHistory(ProductType.SUBS.value) { purchaseHistoryList -> + val storeTransactions = arrayListOf() + storeTransactions.addAll(purchaseHistoryList ?: emptyList()) + CBRestorePurchaseManager.fetchStoreSubscriptionStatus( + storeTransactions = storeTransactions, + allTransactions = arrayListOf(), + activeTransactions = arrayListOf(), + restorePurchases = arrayListOf(), + completionCallback = restorePurchaseCallBack ) } + } else { + restorePurchaseCallBack.onError( + connectionError + ) + } + } + + private fun queryPurchaseHistory( + storeTransactions: (List) -> Unit + ) { + val purchaseTransactionHistory = mutableListOf() + queryAllSubsPurchaseHistory(ProductType.SUBS.value) { subscriptionHistory -> + purchaseTransactionHistory.addAll(subscriptionHistory ?: emptyList()) + queryAllInAppPurchaseHistory(ProductType.INAPP.value) { inAppHistory -> + purchaseTransactionHistory.addAll(inAppHistory ?: emptyList()) + storeTransactions(purchaseTransactionHistory) + } + } + } + + private fun queryAllSubsPurchaseHistory( + productType: String, purchaseTransactionList: (List?) -> Unit + ) { + queryPurchaseHistoryAsync(productType) { + purchaseTransactionList(it) + } + } + + private fun queryAllInAppPurchaseHistory( + productType: String, purchaseTransactionList: (List?) -> Unit + ) { + queryPurchaseHistoryAsync(productType) { + purchaseTransactionList(it) } } - fun queryPurchaseHistory(){ - billingClient.queryPurchaseHistoryAsync(BillingClient.SkuType.SUBS){ billingResult, subsHistoryList -> - if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) { - Log.i(TAG, "queryPurchaseHistory :$subsHistoryList") + private fun queryPurchaseHistoryAsync( + productType: String, purchaseTransactionList: (List?) -> Unit + ) { + val queryPurchaseHistoryParams = QueryPurchaseHistoryParams.newBuilder().setProductType(productType).build() + billingClient?.queryPurchaseHistoryAsync(queryPurchaseHistoryParams) { billingResult, subsHistoryList -> + if (billingResult.responseCode == OK) { + val purchaseHistoryList = subsHistoryList?.map { + it.toPurchaseTransaction(productType) + } + purchaseTransactionList(purchaseHistoryList) } else { - Log.i( - TAG, - "queryPurchaseHistory :${billingResult.debugMessage}" + restorePurchaseCallBack.onError(throwCBException(billingResult)) + } + } + } + + private fun PurchaseHistoryRecord.toPurchaseTransaction(productType: String): PurchaseTransaction { + return PurchaseTransaction( + productId = this.skus, + productType = productType, + purchaseTime = this.purchaseTime, + purchaseToken = this.purchaseToken + ) + } + + private fun buildBillingClient(listener: PurchasesUpdatedListener): BillingClient? { + if (billingClient == null) { + billingClient = mContext?.let { + BillingClient.newBuilder(it).enablePendingPurchases().setListener(listener) + .build() + } + } + return billingClient + } + + private fun onConnected(status: (Boolean) -> Unit, connectionError: (CBException) -> Unit) { + val billingClient = buildBillingClient(this) + requests.add(Pair(status, connectionError)) + if (billingClient?.isReady == false) { + billingClient.startConnection( + createBillingClientStateListener() + ) + } else { + executeRequestsInQueue() + } + } + + @Synchronized + private fun executeRequestsInQueue() { + val head = requests.poll() + if (head != null) { + val successHandler = head.first + handler.post { + successHandler(true) + } + } + } + + @Synchronized + private fun sendErrorToRequestsInQueue(exception: CBException) { + val head = requests.poll() + if(head != null) { + val exceptionHandler = head.second + handler.post { + exceptionHandler(exception) + } + } + } + + private fun createBillingClientStateListener() = object : BillingClientStateListener { + override fun onBillingServiceDisconnected() { + Log.i(javaClass.simpleName, "onBillingServiceDisconnected") + } + + override fun onBillingSetupFinished(billingResult: BillingResult) { + when (billingResult.responseCode) { + OK -> { + Log.i(TAG, "Google Billing Setup Done!") + executeRequestsInQueue() + } else -> { + sendErrorToRequestsInQueue(throwCBException(billingResult)) + } + } + } + } + + internal fun validateReceiptWithChargebee( + product: CBProduct, + completionCallback: CBCallback.PurchaseCallback + ) { + this.purchaseCallBack = completionCallback + onConnected({ status -> + if (status) + queryPurchaseHistory { purchaseHistoryList -> + val purchaseTransaction = purchaseHistoryList.filter { + it.productId.first() == product.id + } + val transaction = purchaseTransaction.firstOrNull() + transaction?.let { + validateReceipt(transaction.purchaseToken, product) + } ?: run { + completionCallback.onError(itemNotOwnedException()) + } + + } else + completionCallback.onError( + connectionError ) + }, { error -> + completionCallback.onError(error) + }) + } + + internal fun purchaseNonSubscriptionProduct( + product: CBProduct, + oneTimePurchaseCallback: CBCallback.OneTimePurchaseCallback + ) { + this.oneTimePurchaseCallback = oneTimePurchaseCallback + onConnected({ status -> + if (status) { + val purchaseParams = PurchaseProductParams(product) + purchase(purchaseParams) + } else { + oneTimePurchaseCallback.onError(connectionError) + } + }, { error -> + oneTimePurchaseCallback.onError(error) + }) + } + + /* Chargebee method called here to validate receipt */ + private fun validateNonSubscriptionReceipt(purchaseToken: String, product: CBProduct) { + CBPurchase.validateNonSubscriptionReceipt(purchaseToken, product) { + when (it) { + is ChargebeeResult.Success -> { + Log.i( + TAG, + "Validate Non-Subscription Receipt Response: ${(it.data as CBNonSubscriptionResponse).nonSubscription}" + ) + if (it.data.nonSubscription != null) { + val invoiceId = (it.data).nonSubscription.invoiceId + Log.i(TAG, "Invoice ID: $invoiceId") + val nonSubscriptionResult = (it.data).nonSubscription + if (invoiceId.isEmpty()) { + oneTimePurchaseCallback?.onSuccess(nonSubscriptionResult, false) + } else { + oneTimePurchaseCallback?.onSuccess(nonSubscriptionResult, true) + } + } else { + oneTimePurchaseCallback?.onError(CBException(ErrorDetail(message = GPErrorCode.PurchaseInvalid.errorMsg))) + } + } + + is ChargebeeResult.Error -> { + Log.e( + TAG, + "Exception from Server - validateNonSubscriptionReceipt() : ${it.exp.message}" + ) + oneTimePurchaseCallback?.onError(it.exp) + } } } } + + internal fun validateNonSubscriptionReceiptWithChargebee( + product: CBProduct, + completionCallback: CBCallback.OneTimePurchaseCallback + ) { + this.oneTimePurchaseCallback = completionCallback + onConnected({ status -> + if (status) + queryPurchaseHistory { purchaseHistoryList -> + val purchaseTransaction = purchaseHistoryList.filter { + it.productId.first() == product.id + } + val transaction = purchaseTransaction.firstOrNull() + transaction?.let { + validateNonSubscriptionReceipt(transaction.purchaseToken, product) + } ?: run { + completionCallback.onError(itemNotOwnedException()) + } + } else + completionCallback.onError( + connectionError + ) + }, { error -> + completionCallback.onError(error) + }) + } } diff --git a/chargebee/src/main/java/com/chargebee/android/billingservice/CBCallback.kt b/chargebee/src/main/java/com/chargebee/android/billingservice/CBCallback.kt index 9126822..7dc8b58 100644 --- a/chargebee/src/main/java/com/chargebee/android/billingservice/CBCallback.kt +++ b/chargebee/src/main/java/com/chargebee/android/billingservice/CBCallback.kt @@ -1,7 +1,7 @@ package com.chargebee.android.billingservice import com.chargebee.android.exceptions.CBException -import com.chargebee.android.models.CBProduct +import com.chargebee.android.models.* import com.chargebee.android.network.ReceiptDetail interface CBCallback { @@ -9,13 +9,24 @@ interface CBCallback { fun onSuccess(productIDs: ArrayList) fun onError(error: CBException) } + interface ListProductsCallback { fun onSuccess(productIDs: ArrayList) fun onError(error: CBException) } + interface PurchaseCallback { fun onSuccess(result: ReceiptDetail, status: Boolean) fun onError(error: CBException) } + interface RestorePurchaseCallback { + fun onSuccess(result: List) + fun onError(error: CBException) + } + + interface OneTimePurchaseCallback { + fun onSuccess(result: NonSubscription, status: Boolean) + fun onError(error: CBException) + } } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/billingservice/CBPurchase.kt b/chargebee/src/main/java/com/chargebee/android/billingservice/CBPurchase.kt index 3ebb4a6..079a2e0 100644 --- a/chargebee/src/main/java/com/chargebee/android/billingservice/CBPurchase.kt +++ b/chargebee/src/main/java/com/chargebee/android/billingservice/CBPurchase.kt @@ -3,7 +3,6 @@ package com.chargebee.android.billingservice import android.content.Context import android.text.TextUtils import android.util.Log -import com.android.billingclient.api.BillingClient import com.chargebee.android.Chargebee import com.chargebee.android.ErrorDetail import com.chargebee.android.exceptions.* @@ -13,124 +12,175 @@ import com.chargebee.android.models.ResultHandler import com.chargebee.android.network.* import com.chargebee.android.resources.CatalogVersion import com.chargebee.android.resources.ReceiptResource -import java.util.ArrayList + object CBPurchase { - var billingClientManager: BillingClientManager? = null - val productIdList = arrayListOf() - private var customer : CBCustomer? = null + private var billingClientManager: BillingClientManager? = null + val productIdList = mutableSetOf() + private var customer: CBCustomer? = null + internal var includeInActivePurchases = false + internal var productType = OneTimeProductType.UNKNOWN - annotation class SkuType { - companion object { - var INAPP = "inapp" - var SUBS = "subs" - } - } /* * Get the product ID's from chargebee system */ @JvmStatic - fun retrieveProductIdentifers(params: Array = arrayOf(), completion : (CBProductIDResult>) -> Unit) { + fun retrieveProductIdentifiers( + params: Array = arrayOf(), + completion: (CBProductIDResult>) -> Unit + ) { if (params.isNotEmpty()) { params[0] = params[0].ifEmpty { Chargebee.limit } val queryParams = append(params) retrieveProductIDList(queryParams, completion) - }else{retrieveProductIDList(arrayOf(), completion) } + } else { + retrieveProductIDList(arrayOf(), completion) + } } - /* Get the product/sku details from Play console */ + + /** + * Get the CBProducts for the given list of product Ids + * @param [context] current activity context + * @param [params] list of product Ids + * @param [callBack] The listener will be called when retrieve products completes. + */ @JvmStatic - fun retrieveProducts(context: Context, params: ArrayList, callBack : CBCallback.ListProductsCallback>) { - try { - val connectionState = billingClientManager?.billingClient?.connectionState - if (connectionState!=null && connectionState == BillingClient.ConnectionState.CONNECTED){ - billingClientManager?.billingClient?.endConnection() - } - billingClientManager = BillingClientManager(context,SkuType.SUBS, params, callBack) - }catch (ex: CBException){ - callBack.onError(ex) - } + fun retrieveProducts( + context: Context, + params: ArrayList, + callBack: CBCallback.ListProductsCallback> + ) { + sharedInstance(context).retrieveProducts(params, callBack) } - /* Buy the product with/without customer Id */ - @Deprecated(message = "This will be removed in upcoming release, Please use API fun - purchaseProduct(product: CBProduct, customer : CBCustomer? = null, callback)", level = DeprecationLevel.WARNING) + /** + * Buy Subscription product with/without customer data + * @param [purchaseProductParams] The purchase parameters of the product to be purchased. + * @param [customer] Optional. Customer Object. + * @param [callback] listener will be called when product purchase completes. + */ @JvmStatic fun purchaseProduct( - product: CBProduct, customerID: String, - callback: CBCallback.PurchaseCallback) { - customer = CBCustomer(customerID,"","","") - purchaseProduct(product, callback) + purchaseProductParams: PurchaseProductParams, customer: CBCustomer? = null, + callback: CBCallback.PurchaseCallback + ) { + this.customer = customer + purchaseProduct(purchaseProductParams, callback) + } + + private fun purchaseProduct(purchaseProductParams: PurchaseProductParams, callback: CBCallback.PurchaseCallback) { + isSDKKeyValid({ + log(customer, purchaseProductParams.product.id) + billingClientManager?.purchase(purchaseProductParams, callback) + }, { + callback.onError(it) + }) } - /* Buy the product with/without customer info */ + /** + * Buy the non-subscription product with/without customer data + * @param [product] The product that wish to purchase + * @param [customer] Optional. Customer Object. + * @param [productType] One time Product Type. Consumable or Non-Consumable + * @param [callback] listener will be called when product purchase completes. + */ @JvmStatic - fun purchaseProduct( + fun purchaseNonSubscriptionProduct( product: CBProduct, customer: CBCustomer? = null, - callback: CBCallback.PurchaseCallback) { + productType: OneTimeProductType, + callback: CBCallback.OneTimePurchaseCallback + ) { this.customer = customer - purchaseProduct(product, callback) + this.productType = productType + + isSDKKeyValid({ + log(CBPurchase.customer, product.id, productType) + billingClientManager?.purchaseNonSubscriptionProduct(product, callback) + }, { + callback.onError(it) + }) } - private fun purchaseProduct(product: CBProduct,callback: CBCallback.PurchaseCallback){ - if (!TextUtils.isEmpty(Chargebee.sdkKey)){ - CBAuthentication.isSDKKeyValid(Chargebee.sdkKey){ - when(it){ + private fun isSDKKeyValid(success: () -> Unit, error: (CBException) -> Unit) { + if (!TextUtils.isEmpty(Chargebee.sdkKey)) { + CBAuthentication.isSDKKeyValid(Chargebee.sdkKey) { + when (it) { is ChargebeeResult.Success -> { if (billingClientManager?.isFeatureSupported() == true) { - if (billingClientManager?.isBillingClientReady() == true) { - billingClientManager?.purchase(product, callback) - } else { - callback.onError(CBException(ErrorDetail(GPErrorCode.BillingClientNotReady.errorMsg))) - } - }else { - callback.onError(CBException(ErrorDetail(GPErrorCode.FeatureNotSupported.errorMsg))) + success() + } else { + error(CBException(ErrorDetail(GPErrorCode.FeatureNotSupported.errorMsg, httpStatusCode = BillingErrorCode.FEATURE_NOT_SUPPORTED.code))) } } - is ChargebeeResult.Error ->{ - Log.i(javaClass.simpleName, "Exception from server :${it.exp.message}") - callback.onError(it.exp) + is ChargebeeResult.Error -> { + error(it.exp) } } } - }else{ - callback.onError(CBException(ErrorDetail(message = GPErrorCode.SDKKeyNotAvailable.errorMsg, httpStatusCode = 400))) + } else { + error( + CBException( + ErrorDetail( + message = GPErrorCode.SDKKeyNotAvailable.errorMsg, + httpStatusCode = 400 + ) + ) + ) } } - /* Chargebee Method - used to validate the receipt of purchase */ + /** + * This method will provide all the purchases associated with the current account based on the [includeInActivePurchases] flag set. + * And the associated purchases will be synced with Chargebee. + * + * @param [context] Current activity context + * @param [customer] Optional. Customer Object. + * @param [includeInActivePurchases] False by default. if true, only active purchases restores and synced with Chargebee. + * @param [completionCallback] The listener will be called when restore purchase completes. + */ @JvmStatic - fun validateReceipt(purchaseToken: String, product: CBProduct, completion : (ChargebeeResult) -> Unit) { - try { - val logger = CBLogger(name = "buy", action = "process_purchase_command") - val params = Params( - purchaseToken, - product.productId, - customer, - Chargebee.channel - ) + fun restorePurchases( + context: Context, + customer: CBCustomer? = null, + includeInActivePurchases: Boolean = false, + completionCallback: CBCallback.RestorePurchaseCallback + ) { + this.includeInActivePurchases = includeInActivePurchases + this.customer = customer + sharedInstance(context).restorePurchases(completionCallback) + } - ResultHandler.safeExecuter( - { ReceiptResource().validateReceipt(params) }, - completion, - logger - ) - }catch (exp: Exception){ - Log.e(javaClass.simpleName, "Exception in validateReceipt() :"+exp.message) - ChargebeeResult.Error( - exp = CBException( - error = ErrorDetail( - exp.message - ) - ) - ) - } + /** + * This method will be used to validate the receipt with Chargebee, + * when syncing with Chargebee fails after the successful purchase in Google Play Store. + * + * @param [context] Current activity context + * @param [productId] Product Identifier. + * @param [customer] Optional. Customer Object. + * @param [completionCallback] The listener will be called when validate receipt completes. + */ + @JvmStatic + fun validateReceipt( + context: Context, + product: CBProduct, + customer: CBCustomer? = null, + completionCallback: CBCallback.PurchaseCallback + ) { + this.customer = customer + sharedInstance(context).validateReceiptWithChargebee(product, completionCallback) } + + /* Chargebee Method - used to validate the receipt of purchase */ @JvmStatic - @Throws(InvalidRequestException::class, OperationFailedException::class) - fun queryPurchaseHistory() { + internal fun validateReceipt( + purchaseToken: String, + product: CBProduct, + completion: (ChargebeeResult) -> Unit + ) { try { - billingClientManager?.queryPurchaseHistory() - }catch (exp: Exception){ - Log.i(javaClass.simpleName, "Exception in validateReceipt() :"+exp.message) + validateReceipt(purchaseToken, product.id, completion) + } catch (exp: Exception) { + Log.e(javaClass.simpleName, "Exception in validateReceipt() :" + exp.message) ChargebeeResult.Error( exp = CBException( error = ErrorDetail( @@ -140,105 +190,197 @@ object CBPurchase { ) } } + + internal fun validateReceipt( + purchaseToken: String, + productId: String, + completion: (ChargebeeResult) -> Unit + ) { + val logger = CBLogger(name = "buy", action = "process_purchase_command", + additionalInfo = mapOf("customerId" to (customer?.id ?: ""), "product" to productId, "purchaseToken" to purchaseToken)) + val params = Params( + purchaseToken, + productId, + customer, + Chargebee.channel, + null + ) + ResultHandler.safeExecuter( + { ReceiptResource().validateReceipt(params) }, + completion, + logger + ) + } + + /** + * This method will be used to validate the receipt with Chargebee, + * when syncing with Chargebee fails after the successful purchase in Google Play Store. + * + * @param [context] Current activity context + * @param [productId] Product Identifier. + * @param [customer] Optional. Customer Object. + * @param [productType] Product Type. Consumable or Non-Consumable product + * @param [completionCallback] The listener will be called when validate receipt completes. + */ @JvmStatic - @Throws(InvalidRequestException::class, OperationFailedException::class) - fun queryAllPurchases() { - try { - billingClientManager?.queryAllPurchases() - }catch (exp: Exception){ - Log.i(javaClass.simpleName, "Exception in validateReceipt() :"+exp.message) - ChargebeeResult.Error( - exp = CBException( - error = ErrorDetail( - exp.message - ) - ) - ) - } + fun validateReceiptForNonSubscriptions( + context: Context, + product: CBProduct, + customer: CBCustomer? = null, + productType: OneTimeProductType, + completionCallback: CBCallback.OneTimePurchaseCallback + ) { + this.customer = customer + this.productType = productType + sharedInstance(context).validateNonSubscriptionReceiptWithChargebee(product, completionCallback) + } + + internal fun validateNonSubscriptionReceipt( + purchaseToken: String, + product: CBProduct, + completion: (ChargebeeResult) -> Unit + ) { + validateNonSubscriptionReceipt(purchaseToken, product.id, completion) + } + + internal fun validateNonSubscriptionReceipt( + purchaseToken: String, + productId: String, + completion: (ChargebeeResult) -> Unit + ) { + val logger = CBLogger(name = "buy", action = "one_time_purchase", + additionalInfo = mapOf("customerId" to (customer?.id ?: ""), "product" to productId, "purchaseToken" to purchaseToken)) + val params = Params( + purchaseToken, + productId, + customer, + Chargebee.channel, + productType + ) + ResultHandler.safeExecuter( + { ReceiptResource().validateReceiptForNonSubscription(params) }, + completion, + logger + ) } /* * Get the product ID's from chargebee system. */ - fun retrieveProductIDList(params: Array, completion: (CBProductIDResult>) -> Unit){ + internal fun retrieveProductIDList( + params: Array, + completion: (CBProductIDResult>) -> Unit + ) { // The Plan will be fetched based on the user catalog versions in chargebee system. - when(Chargebee.version){ + when (Chargebee.version) { // If user catalog version1 then get the plan's - CatalogVersion.V1.value ->{ - Chargebee.retrieveAllPlans(params){ + CatalogVersion.V1.value -> { + Chargebee.retrieveAllPlans(params) { when (it) { is ChargebeeResult.Success -> { Log.i(javaClass.simpleName, "list plan ID's : ${it.data}") val productsList = (it.data as PlansWrapper).list productIdList.clear() - for (plan in productsList){ + for (plan in productsList) { if (!TextUtils.isEmpty(plan.plan.channel)) { val id = plan.plan.id.split("-") productIdList.add(id[0]) } } - completion(CBProductIDResult.ProductIds(productIdList)) + completion(CBProductIDResult.ProductIds(ArrayList(productIdList))) } is ChargebeeResult.Error -> { - Log.e(javaClass.simpleName, "Error retrieving all plans : ${it.exp.message}") + Log.e( + javaClass.simpleName, + "Error retrieving all plans : ${it.exp.message}" + ) completion(CBProductIDResult.Error(CBException(ErrorDetail(it.exp.message)))) } } } } // If user catalog version2 then get the Item's - CatalogVersion.V2.value ->{ - Chargebee.retrieveAllItems(params){ + CatalogVersion.V2.value -> { + Chargebee.retrieveAllItems(params) { when (it) { is ChargebeeResult.Success -> { Log.i(javaClass.simpleName, "list item ID's : ${it.data}") val productsList = (it.data as ItemsWrapper).list productIdList.clear() - for (item in productsList){ - productIdList.add(item.item.id) + for (item in productsList) { + val id = item.item.id.split("-") + productIdList.add(id[0]) } - completion(CBProductIDResult.ProductIds(productIdList)) + completion(CBProductIDResult.ProductIds(ArrayList(productIdList))) } is ChargebeeResult.Error -> { - Log.e(javaClass.simpleName, "Error retrieving all items : ${it.exp.message}") + Log.e( + javaClass.simpleName, + "Error retrieving all items : ${it.exp.message}" + ) completion(CBProductIDResult.Error(CBException(ErrorDetail(it.exp.message)))) } } } } // Check the catalog version - CatalogVersion.Unknown.value ->{ - val auth = Auth(Chargebee.sdkKey, + CatalogVersion.Unknown.value -> { + val auth = Auth( + Chargebee.sdkKey, Chargebee.applicationId, Chargebee.appName, Chargebee.channel ) CBAuthentication.authenticate(auth) { - when(it){ - is ChargebeeResult.Success ->{ + when (it) { + is ChargebeeResult.Success -> { Log.i(javaClass.simpleName, " Response :${it.data}") val response = it.data as CBAuthResponse Chargebee.version = response.in_app_detail.product_catalog_version - retrieveProductIDList(params,completion) + retrieveProductIDList(params, completion) } - is ChargebeeResult.Error ->{ + is ChargebeeResult.Error -> { Log.i(javaClass.simpleName, "Invalid catalog version") completion(CBProductIDResult.Error(CBException(ErrorDetail("Invalid catalog version")))) } } } } - else ->{ + else -> { Log.i(javaClass.simpleName, "Unknown error") completion(CBProductIDResult.Error(CBException(ErrorDetail("Unknown error")))) } } } - fun append(arr: Array): Array { + + internal fun append(arr: Array): Array { val list: MutableList = arr.toMutableList() - if (arr.size==1) list.add("Standard") + if (arr.size == 1) list.add("Standard") return list.toTypedArray() } + private fun sharedInstance(context: Context): BillingClientManager { + if (billingClientManager == null) { + billingClientManager = BillingClientManager(context) + } + return billingClientManager as BillingClientManager + } + + private fun log(customer: CBCustomer?, productId: String, productType: OneTimeProductType? = null) { + val additionalInfo = additionalInfo(customer, productId, productType) + val logger = CBLogger( + name = "buy", + action = "before_purchase_command", + additionalInfo = additionalInfo + ) + ResultHandler.safeExecute { logger.info() } + } + private fun additionalInfo(customer: CBCustomer?, productId: String, productType: OneTimeProductType? = null): Map { + val map = mutableMapOf("product" to productId) + customer?.let { map["customerId"] = (it.id ?: "") } + productType?.let { map["productType"] = it.toString() } + return map + } + } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/billingservice/GPErrorCode.kt b/chargebee/src/main/java/com/chargebee/android/billingservice/GPErrorCode.kt index ba38426..c643653 100644 --- a/chargebee/src/main/java/com/chargebee/android/billingservice/GPErrorCode.kt +++ b/chargebee/src/main/java/com/chargebee/android/billingservice/GPErrorCode.kt @@ -1,5 +1,10 @@ package com.chargebee.android.billingservice +import com.android.billingclient.api.BillingClient +import com.android.billingclient.api.BillingResult +import com.chargebee.android.ErrorDetail +import com.chargebee.android.exceptions.CBException + enum class GPErrorCode(val errorMsg: String) { BillingUnavailable("The Billing API version is not supported"), PurchasePending("Purchase is in pending state"), @@ -18,4 +23,84 @@ enum class GPErrorCode(val errorMsg: String) { DeveloperError("Invalid arguments provided to the API"), BillingClientNotReady("Play services not available"), SDKKeyNotAvailable("SDK key not available to proceed purchase"), + InvalidPurchaseToken("The Token data sent is not correct or Google service is temporarily down"), +} + +internal enum class BillingErrorCode(val code: Int, val message: String) { + UNKNOWN(-4, "Unknown error occurred"), + SERVICE_TIMEOUT(-3, "The request has reached the maximum timeout before Google Play responds"), + FEATURE_NOT_SUPPORTED( + -2, + "The requested feature is not supported by the Play Store on the current device" + ), + USER_CANCELED(1, "Transaction was canceled by the user"), + SERVICE_UNAVAILABLE(2, "The service is currently unavailable"), + BILLING_UNAVAILABLE(3, "A user billing error occurred during processing"), + ITEM_UNAVAILABLE(4, "The requested product is not available for purchase"), + DEVELOPER_ERROR(5, "Error resulting from incorrect usage of the API"), + ERROR(6, "Fatal error during the API action"), + ITEM_NOT_OWNED(8, "Requested action on the item failed since it is not owned by the user"), + SERVICE_DISCONNECTED( + -1, + "The app is not connected to the Play Store service via the Google Play Billing Library" + ), + ITEM_ALREADY_OWNED(7, "The purchase failed because the item is already owned"); + + companion object { + private fun errorDetail(responseCode: Int): ErrorDetail = + when (responseCode) { + BillingClient.BillingResponseCode.SERVICE_TIMEOUT -> ErrorDetail( + message = SERVICE_TIMEOUT.message, + httpStatusCode = SERVICE_TIMEOUT.code + ) + BillingClient.BillingResponseCode.FEATURE_NOT_SUPPORTED -> ErrorDetail( + message = FEATURE_NOT_SUPPORTED.message, + httpStatusCode = FEATURE_NOT_SUPPORTED.code + ) + BillingClient.BillingResponseCode.USER_CANCELED -> ErrorDetail( + message = USER_CANCELED.message, + httpStatusCode = USER_CANCELED.code + ) + BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE -> ErrorDetail( + message = SERVICE_UNAVAILABLE.message, + httpStatusCode = SERVICE_UNAVAILABLE.code + ) + BillingClient.BillingResponseCode.BILLING_UNAVAILABLE -> ErrorDetail( + message = BILLING_UNAVAILABLE.message, + httpStatusCode = BILLING_UNAVAILABLE.code + ) + BillingClient.BillingResponseCode.ITEM_UNAVAILABLE -> ErrorDetail( + message = ITEM_UNAVAILABLE.message, + httpStatusCode = ITEM_UNAVAILABLE.code + ) + BillingClient.BillingResponseCode.DEVELOPER_ERROR -> ErrorDetail( + message = DEVELOPER_ERROR.message, + httpStatusCode = DEVELOPER_ERROR.code + ) + BillingClient.BillingResponseCode.ERROR -> ErrorDetail( + message = ERROR.message, + httpStatusCode = ERROR.code + ) + BillingClient.BillingResponseCode.ITEM_NOT_OWNED -> ErrorDetail( + message = ITEM_NOT_OWNED.message, + httpStatusCode = ITEM_NOT_OWNED.code + ) + BillingClient.BillingResponseCode.SERVICE_DISCONNECTED -> ErrorDetail( + message = SERVICE_DISCONNECTED.message, + httpStatusCode = SERVICE_DISCONNECTED.code + ) + BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED -> ErrorDetail( + message = ITEM_ALREADY_OWNED.message, + httpStatusCode = ITEM_ALREADY_OWNED.code + ) + else -> { + ErrorDetail(message = UNKNOWN.message, httpStatusCode = UNKNOWN.code) + } + } + + internal fun throwCBException(billingResult: BillingResult): CBException = + CBException( + errorDetail(billingResult.responseCode) + ) + } } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/billingservice/OneTimeProductType.kt b/chargebee/src/main/java/com/chargebee/android/billingservice/OneTimeProductType.kt new file mode 100644 index 0000000..d1dc5d8 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/billingservice/OneTimeProductType.kt @@ -0,0 +1,7 @@ +package com.chargebee.android.billingservice + +enum class OneTimeProductType(val value: String) { + UNKNOWN(""), + CONSUMABLE("consumable"), + NON_CONSUMABLE("non_consumable") +} \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/billingservice/ProductType.kt b/chargebee/src/main/java/com/chargebee/android/billingservice/ProductType.kt new file mode 100644 index 0000000..91064af --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/billingservice/ProductType.kt @@ -0,0 +1,12 @@ +package com.chargebee.android.billingservice + +import java.util.* + +enum class ProductType(val value: String) { + SUBS("subs"), + INAPP("inapp"); + + companion object { + fun getProductType(value: String): ProductType = ProductType.valueOf(value.toUpperCase(Locale.ROOT)) + } +} \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/loggers/CBLogger.kt b/chargebee/src/main/java/com/chargebee/android/loggers/CBLogger.kt index b492017..64684d1 100644 --- a/chargebee/src/main/java/com/chargebee/android/loggers/CBLogger.kt +++ b/chargebee/src/main/java/com/chargebee/android/loggers/CBLogger.kt @@ -6,7 +6,8 @@ import com.chargebee.android.resources.LogType import com.chargebee.android.resources.LoggerResource class CBLogger(private val name: String, - private val action: String) { + private val action: String, + private val additionalInfo: Map? = null) { suspend fun error(message: String, code: Int? = null) { postLog(LogType.ERROR, message, code) @@ -34,7 +35,8 @@ class CBLogger(private val name: String, deviceModelName, platform, osVersion, - sdkVersion + sdkVersion, + additionalInfo ) } } diff --git a/chargebee/src/main/java/com/chargebee/android/models/CBNonSubscriptionResponse.kt b/chargebee/src/main/java/com/chargebee/android/models/CBNonSubscriptionResponse.kt new file mode 100644 index 0000000..96b7da9 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/models/CBNonSubscriptionResponse.kt @@ -0,0 +1,17 @@ +package com.chargebee.android.models + +import com.google.gson.annotations.SerializedName + +data class NonSubscription( + @SerializedName("invoice_id") + val invoiceId: String, + @SerializedName("customer_id") + val customerId: String, + @SerializedName("charge_id") + val chargeId: String +) + +data class CBNonSubscriptionResponse( + @SerializedName("non_subscription") + val nonSubscription: NonSubscription +) diff --git a/chargebee/src/main/java/com/chargebee/android/models/CBRestoreSubscription.kt b/chargebee/src/main/java/com/chargebee/android/models/CBRestoreSubscription.kt new file mode 100644 index 0000000..2238a11 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/models/CBRestoreSubscription.kt @@ -0,0 +1,24 @@ +package com.chargebee.android.models + +import com.google.gson.annotations.SerializedName + +data class CBRestoreSubscription( + @SerializedName("subscription_id") + val subscriptionId: String, + @SerializedName("plan_id") + val planId: String, + @SerializedName("store_status") + val storeStatus: String +) + +data class CBRestorePurchases( + @SerializedName("in_app_subscriptions") + val inAppSubscriptions: ArrayList +) + +enum class StoreStatus(val value: String) { + Active("active"), + InTrial("in_trial"), + Cancelled("cancelled"), + Paused("paused") +} \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/models/Products.kt b/chargebee/src/main/java/com/chargebee/android/models/Products.kt index 3265a4b..a915bf6 100644 --- a/chargebee/src/main/java/com/chargebee/android/models/Products.kt +++ b/chargebee/src/main/java/com/chargebee/android/models/Products.kt @@ -1,6 +1,26 @@ package com.chargebee.android.models -import com.android.billingclient.api.SkuDetails +import com.chargebee.android.billingservice.ProductType -data class CBProduct(val productId: String,val productTitle:String, val productPrice: String, var skuDetails: SkuDetails, var subStatus: Boolean ) { -} \ No newline at end of file +data class CBProduct( + val id: String, + val title: String, + val description: String, + val type: ProductType, + val subscriptionOffers: List?, + val oneTimePurchaseOffer: PricingPhase?, +) + +data class SubscriptionOffer( + val basePlanId: String, + val offerId: String?, + val offerToken: String, + val pricingPhases: List +) +data class PricingPhase( + val formattedPrice: String, + val amountInMicros: Long, + val currencyCode: String, + val billingPeriod: String? = null, + val billingCycleCount: Int? = null +) diff --git a/chargebee/src/main/java/com/chargebee/android/models/PurchaseProductParams.kt b/chargebee/src/main/java/com/chargebee/android/models/PurchaseProductParams.kt new file mode 100644 index 0000000..1d4dd71 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/models/PurchaseProductParams.kt @@ -0,0 +1,6 @@ +package com.chargebee.android.models + +data class PurchaseProductParams( + val product: CBProduct, + val offerToken: String? = null +) \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/models/PurchaseTransaction.kt b/chargebee/src/main/java/com/chargebee/android/models/PurchaseTransaction.kt new file mode 100644 index 0000000..291eaa7 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/models/PurchaseTransaction.kt @@ -0,0 +1,8 @@ +package com.chargebee.android.models + +data class PurchaseTransaction( + val productId: List, + val purchaseTime: Long, + val purchaseToken: String, + val productType: String +) diff --git a/chargebee/src/main/java/com/chargebee/android/models/ResultHandler.kt b/chargebee/src/main/java/com/chargebee/android/models/ResultHandler.kt index 09f23a9..b45b142 100644 --- a/chargebee/src/main/java/com/chargebee/android/models/ResultHandler.kt +++ b/chargebee/src/main/java/com/chargebee/android/models/ResultHandler.kt @@ -33,6 +33,7 @@ internal class ResultHandler { completion(result) } } + fun safeExecuter( codeBlock: suspend () -> ChargebeeResult, completion: (ChargebeeResult) -> Unit, @@ -48,21 +49,46 @@ internal class ResultHandler { ChargebeeResult.Error(ex) } catch (ex: UnknownHostException) { print("failed: ${ex.message}") - ChargebeeResult.Error(exp = CBException(ErrorDetail(ex.message))) + ChargebeeResult.Error( + exp = CBException( + ErrorDetail( + ex.message, + httpStatusCode = 502 + ) + ) + ) } catch (ex: Exception) { - logger?.error(ex.message ?: "failed") - ChargebeeResult.Error(exp = CBException(ErrorDetail(ex.message))) + try { + logger?.error(ex.message ?: "failed") + } catch (ex: Exception) { + print("Exception : ${ex.message}") + } + ChargebeeResult.Error( + exp = CBException( + ErrorDetail( + ex.message, + httpStatusCode = 502 + ) + ) + ) } completion(result) } - }catch (exp: Exception){ + } catch (exp: Exception) { print("failed: ${exp.message}") ChargebeeResult.Error(exp = CBException(ErrorDetail(exp.message))) } } - private fun coroutineExceptionHandler() : CoroutineExceptionHandler { - val coroutineExceptionHandler = CoroutineExceptionHandler{_, throwable -> + + fun safeExecute(codeBlock: suspend () -> Any) { + CoroutineScope(Dispatchers.IO).launch { + codeBlock() + } + } + + private fun coroutineExceptionHandler(): CoroutineExceptionHandler { + val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> print("CoroutineExceptionHandler : ${throwable.message}") } return coroutineExceptionHandler diff --git a/chargebee/src/main/java/com/chargebee/android/network/CBReceiptRequestBody.kt b/chargebee/src/main/java/com/chargebee/android/network/CBReceiptRequestBody.kt index 470b626..d21945e 100644 --- a/chargebee/src/main/java/com/chargebee/android/network/CBReceiptRequestBody.kt +++ b/chargebee/src/main/java/com/chargebee/android/network/CBReceiptRequestBody.kt @@ -1,16 +1,23 @@ package com.chargebee.android.network -internal class CBReceiptRequestBody( val receipt: String, - val productId: String, - val customer: CBCustomer?, - val channel: String) { +import android.text.TextUtils +import com.chargebee.android.billingservice.OneTimeProductType + +internal class CBReceiptRequestBody( + val receipt: String, + val productId: String, + val customer: CBCustomer?, + val channel: String, + val productType: OneTimeProductType? +) { companion object { fun fromCBReceiptReqBody(params: Params): CBReceiptRequestBody { return CBReceiptRequestBody( - params.receipt, + params.receipt, params.productId, params.customer, - params.channel + params.channel, + params.productType ) } } @@ -25,16 +32,20 @@ internal class CBReceiptRequestBody( val receipt: String, } fun toCBReceiptReqCustomerBody(): Map { - return mapOf( + val params = mutableMapOf( "receipt" to this.receipt, "product[id]" to this.productId, - "customer[id]" to this.customer?.id, "customer[first_name]" to this.customer?.firstName, "customer[last_name]" to this.customer?.lastName, "customer[email]" to this.customer?.email, "channel" to this.channel ) + if(!TextUtils.isEmpty(this.customer?.id)) { + params["customer[id]"] = this.customer?.id + } + return params } + fun toMap(): Map { return mapOf( "receipt" to this.receipt, @@ -42,14 +53,41 @@ internal class CBReceiptRequestBody( val receipt: String, "channel" to this.channel ) } + + fun toCBNonSubscriptionReqCustomerBody(): Map { + val params = mutableMapOf( + "receipt" to this.receipt, + "product[id]" to this.productId, + "customer[first_name]" to this.customer?.firstName, + "customer[last_name]" to this.customer?.lastName, + "customer[email]" to this.customer?.email, + "channel" to this.channel, + "product[type]" to this.productType?.value + ) + if(!TextUtils.isEmpty(this.customer?.id)) { + params["customer[id]"] = this.customer?.id + } + return params + } + + fun toMapNonSubscription(): Map { + return mapOf( + "receipt" to this.receipt, + "product[id]" to this.productId, + "channel" to this.channel, + "product[type]" to this.productType?.value + ) + } } data class Params( val receipt: String, val productId: String, val customer: CBCustomer?, - val channel: String + val channel: String, + val productType: OneTimeProductType? ) + data class CBCustomer( val id: String?, val firstName: String?, diff --git a/chargebee/src/main/java/com/chargebee/android/repository/PurchaseRepository.kt b/chargebee/src/main/java/com/chargebee/android/repository/PurchaseRepository.kt index 1e20f71..ab999c4 100644 --- a/chargebee/src/main/java/com/chargebee/android/repository/PurchaseRepository.kt +++ b/chargebee/src/main/java/com/chargebee/android/repository/PurchaseRepository.kt @@ -1,10 +1,8 @@ package com.chargebee.android.repository import com.chargebee.android.Chargebee -import com.chargebee.android.models.CBEntitlements -import com.chargebee.android.models.CBSubscription +import com.chargebee.android.models.* import com.chargebee.android.models.KeyValidationWrapper -import com.chargebee.android.models.SubscriptionDetailsWrapper import retrofit2.Response import retrofit2.http.* @@ -40,4 +38,13 @@ internal interface PurchaseRepository { @Header("version") sdkVersion: String = Chargebee.sdkVersion, @Path("subscription_id") subscriptionId: String ): Response + + @FormUrlEncoded + @POST("v2/in_app_subscriptions/{sdkKey}/retrieve") + suspend fun restoreSubscription( + @Header("Authorization") token: String = Chargebee.encodedApiKey, + @Header("platform") platform: String = Chargebee.platform, + @Header("version") sdkVersion: String = Chargebee.sdkVersion, + @Path("sdkKey") sdkKey: String = Chargebee.sdkKey, + @FieldMap data: Map): Response } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/repository/ReceiptRepository.kt b/chargebee/src/main/java/com/chargebee/android/repository/ReceiptRepository.kt index 5850a7f..424039a 100644 --- a/chargebee/src/main/java/com/chargebee/android/repository/ReceiptRepository.kt +++ b/chargebee/src/main/java/com/chargebee/android/repository/ReceiptRepository.kt @@ -1,6 +1,7 @@ package com.chargebee.android.repository import com.chargebee.android.Chargebee +import com.chargebee.android.models.CBNonSubscriptionResponse import com.chargebee.android.network.CBReceiptResponse import retrofit2.Response import retrofit2.http.* @@ -15,4 +16,13 @@ interface ReceiptRepository { @Header("version") sdkVersion: String = Chargebee.sdkVersion, @Path("sdkKey") sdkKey: String = Chargebee.sdkKey, @FieldMap data: Map): Response + + @FormUrlEncoded + @POST("v2/non_subscriptions/{sdkKey}/one_time_purchase/") + suspend fun validateReceiptForNonSubscription( + @Header("Authorization") token: String = Chargebee.encodedApiKey, + @Header("platform") platform: String = Chargebee.platform, + @Header("version") sdkVersion: String = Chargebee.sdkVersion, + @Path("sdkKey") sdkKey: String = Chargebee.sdkKey, + @FieldMap data: Map): Response } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/resources/LoggerResource.kt b/chargebee/src/main/java/com/chargebee/android/resources/LoggerResource.kt index 0088493..b0a093d 100644 --- a/chargebee/src/main/java/com/chargebee/android/resources/LoggerResource.kt +++ b/chargebee/src/main/java/com/chargebee/android/resources/LoggerResource.kt @@ -9,20 +9,29 @@ import com.chargebee.android.repository.LoggerRepository internal class LoggerResource: BaseResource(Chargebee.baseUrl) { - suspend fun log(action: String, type: LogType, errorMessage: String? = null, errorCode: Int? = null, deviceModelName: String? = null, - platform: String? = null, - osVersion: String? = null, - sdkVersion: String? = null): CBResult { - var data = logData(action, type, errorMessage, errorCode,deviceModelName,platform, osVersion, sdkVersion) + suspend fun log( + action: String, type: LogType, errorMessage: String? = null, errorCode: Int? = null, deviceModelName: String? = null, + platform: String? = null, + osVersion: String? = null, + sdkVersion: String? = null, + additionalInfo: Map? = null): CBResult { + var data = logData(action, type, errorMessage, errorCode,deviceModelName,platform, osVersion, sdkVersion, additionalInfo) val logDetail = LogDetail(data = data) apiClient.create(LoggerRepository::class.java).log(logDetail = logDetail) return Success(null) } - private fun logData(action: String, type: LogType, errorMessage: String?, errorCode: Int?,deviceModelName: String? = null, - platform: String? = null, - osVersion: String? = null, - sdkVersion: String? = null): MutableMap { + private fun logData( + action: String, + type: LogType, + errorMessage: String?, + errorCode: Int?, + deviceModelName: String? = null, + platform: String? = null, + osVersion: String? = null, + sdkVersion: String? = null, + additionalInfo: Map? = null + ): MutableMap { var data = mutableMapOf( "key" to "cb.logging", "ref_module" to Chargebee.environment, @@ -36,6 +45,7 @@ internal class LoggerResource: BaseResource(Chargebee.baseUrl) { ) errorMessage?.let { data["error_message"] = it } errorCode?.let { data["error_code"] = "$it" } + additionalInfo?.let { data.putAll(it) } Log.i(javaClass.simpleName, "logData :$data") return data } diff --git a/chargebee/src/main/java/com/chargebee/android/resources/ReceiptResource.kt b/chargebee/src/main/java/com/chargebee/android/resources/ReceiptResource.kt index 07f4921..4ae3ee6 100644 --- a/chargebee/src/main/java/com/chargebee/android/resources/ReceiptResource.kt +++ b/chargebee/src/main/java/com/chargebee/android/resources/ReceiptResource.kt @@ -12,9 +12,8 @@ import com.chargebee.android.responseFromServer internal class ReceiptResource : BaseResource(baseUrl = Chargebee.baseUrl){ internal suspend fun validateReceipt(params: Params): ChargebeeResult { - var dataMap = mapOf() val paramDetail = CBReceiptRequestBody.fromCBReceiptReqBody(params) - dataMap = if (params.customer != null && !(TextUtils.isEmpty(params.customer.id))) { + val dataMap = if (params.customer != null) { paramDetail.toCBReceiptReqCustomerBody() } else{ paramDetail.toMap() @@ -28,4 +27,19 @@ internal class ReceiptResource : BaseResource(baseUrl = Chargebee.baseUrl){ ) } + internal suspend fun validateReceiptForNonSubscription(params: Params): ChargebeeResult { + val paramDetail = CBReceiptRequestBody.fromCBReceiptReqBody(params) + val dataMap = if (params.customer != null) { + paramDetail.toCBNonSubscriptionReqCustomerBody() + } else{ + paramDetail.toMapNonSubscription() + } + val response = apiClient.create(ReceiptRepository::class.java) + .validateReceiptForNonSubscription(data = dataMap) + + Log.i(javaClass.simpleName, " validateReceiptForNonSubscription Response :$response") + return responseFromServer( + response + ) + } } \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/resources/RestorePurchaseResource.kt b/chargebee/src/main/java/com/chargebee/android/resources/RestorePurchaseResource.kt new file mode 100644 index 0000000..10353ae --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/resources/RestorePurchaseResource.kt @@ -0,0 +1,24 @@ +package com.chargebee.android.resources + +import com.chargebee.android.Chargebee +import com.chargebee.android.exceptions.ChargebeeResult +import com.chargebee.android.repository.PurchaseRepository +import com.chargebee.android.responseFromServer + +internal class RestorePurchaseResource : BaseResource(Chargebee.baseUrl) { + + internal suspend fun retrieveStoreSubscription(purchaseToken: String): ChargebeeResult { + val dataMap = convertToMap(purchaseToken) + val response = apiClient.create(PurchaseRepository::class.java) + .restoreSubscription(data = dataMap) + return responseFromServer( + response + ) + } + + private fun convertToMap(receipt: String): Map { + return mapOf( + "receipt" to receipt + ) + } +} \ No newline at end of file diff --git a/chargebee/src/main/java/com/chargebee/android/restore/CBRestorePurchaseManager.kt b/chargebee/src/main/java/com/chargebee/android/restore/CBRestorePurchaseManager.kt new file mode 100644 index 0000000..6cb8c83 --- /dev/null +++ b/chargebee/src/main/java/com/chargebee/android/restore/CBRestorePurchaseManager.kt @@ -0,0 +1,142 @@ +package com.chargebee.android.restore + +import android.util.Log +import com.chargebee.android.ErrorDetail +import com.chargebee.android.billingservice.CBCallback +import com.chargebee.android.billingservice.CBPurchase +import com.chargebee.android.billingservice.GPErrorCode +import com.chargebee.android.billingservice.ProductType +import com.chargebee.android.exceptions.CBException +import com.chargebee.android.exceptions.ChargebeeResult +import com.chargebee.android.loggers.CBLogger +import com.chargebee.android.models.* +import com.chargebee.android.models.ResultHandler +import com.chargebee.android.resources.RestorePurchaseResource + +class CBRestorePurchaseManager { + + companion object { + private lateinit var completionCallback: CBCallback.RestorePurchaseCallback + + private fun retrieveStoreSubscription( + purchaseToken: String, + completion: (ChargebeeResult) -> Unit + ) { + val logger = CBLogger(name = "restore", action = "retrieve_restore_subscriptions") + ResultHandler.safeExecuter( + { RestorePurchaseResource().retrieveStoreSubscription(purchaseToken) }, + completion, + logger + ) + } + + internal fun retrieveRestoreSubscription( + purchaseToken: String, + result: (CBRestoreSubscription) -> Unit, + error: (CBException) -> Unit + ) { + retrieveStoreSubscription(purchaseToken) { + when (it) { + is ChargebeeResult.Success -> { + val restoreSubscription = + ((it.data) as CBRestorePurchases).inAppSubscriptions.firstOrNull() + restoreSubscription?.let { + result(restoreSubscription) + } + } + is ChargebeeResult.Error -> { + error(it.exp) + } + } + } + } + + internal fun fetchStoreSubscriptionStatus( + storeTransactions: ArrayList, + allTransactions: ArrayList, + activeTransactions: ArrayList, + restorePurchases: ArrayList, + completionCallback: CBCallback.RestorePurchaseCallback + ) { + this.completionCallback = completionCallback + if (storeTransactions.isNotEmpty()) { + val storeTransaction = + storeTransactions.firstOrNull()?.also { storeTransactions.remove(it) } + storeTransaction?.purchaseToken?.let { purchaseToken -> + retrieveRestoreSubscription(purchaseToken, { + restorePurchases.add(it) + when (it.storeStatus) { + StoreStatus.Active.value -> { + activeTransactions.add(storeTransaction) + allTransactions.add(storeTransaction) + } + else -> allTransactions.add(storeTransaction) + } + getRestorePurchases(storeTransactions, allTransactions, activeTransactions, restorePurchases) + }, { _ -> + getRestorePurchases(storeTransactions, allTransactions, activeTransactions, restorePurchases) + }) + } + } else { + completionCallback.onSuccess(emptyList()) + } + } + + internal fun getRestorePurchases( + storeTransactions: ArrayList, + allTransactions: ArrayList, + activeTransactions: ArrayList, + restorePurchases: ArrayList + ) { + if (storeTransactions.isEmpty()) { + if (restorePurchases.isEmpty()) { + completionCallback.onError( + CBException( + ErrorDetail( + message = GPErrorCode.InvalidPurchaseToken.errorMsg, + httpStatusCode = 400 + ) + ) + ) + } else { + val activePurchases = restorePurchases.filter { subscription -> + subscription.storeStatus == StoreStatus.Active.value + } + if (CBPurchase.includeInActivePurchases) { + completionCallback.onSuccess(restorePurchases) + syncPurchaseWithChargebee(allTransactions) + } else { + completionCallback.onSuccess(activePurchases) + syncPurchaseWithChargebee(activeTransactions) + } + } + } else { + fetchStoreSubscriptionStatus(storeTransactions,allTransactions, activeTransactions,restorePurchases, completionCallback) + } + } + + internal fun syncPurchaseWithChargebee(storeTransactions: ArrayList) { + storeTransactions.forEach { purchaseTransaction -> + if (purchaseTransaction.productType == ProductType.SUBS.value) { + validateReceipt(purchaseTransaction.purchaseToken, purchaseTransaction.productId.first()) + } + } + } + + internal fun validateReceipt(purchaseToken: String, productId: String) { + CBPurchase.validateReceipt(purchaseToken, productId) { + when (it) { + is ChargebeeResult.Success -> { + Log.i(javaClass.simpleName, "result : ${it.data}") + } + is ChargebeeResult.Error -> { + Log.e( + javaClass.simpleName, + "Exception from Server - validateReceipt() : ${it.exp.message}" + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/chargebee/src/test/java/com/android/billingclient/api/StubProductDetails.kt b/chargebee/src/test/java/com/android/billingclient/api/StubProductDetails.kt new file mode 100644 index 0000000..9bff30c --- /dev/null +++ b/chargebee/src/test/java/com/android/billingclient/api/StubProductDetails.kt @@ -0,0 +1,38 @@ +package com.android.billingclient.api + +import kotlin.reflect.KClass + +fun KClass.create(): ProductDetails { + val productDetails = ProductDetails("{\n" + + "\t\"productId\": \"gold\",\n" + + "\t\"type\": \"subs\",\n" + + "\t\"title\": \"Gold Plan (com.chargebee.newsample (unreviewed))\",\n" + + "\t\"name\": \"Gold Plan\",\n" + + "\t\"localizedIn\": [\"en-US\"],\n" + + "\t\"skuDetailsToken\": \"AEuhp4Ln65Xw7Do9yIO-o4XIj7rAvn_FD90WWajD79kzt0GiNuNm2ACU15T3q56qZTs=\",\n" + + "\t\"subscriptionOfferDetails\": [{\n" + + "\t\t\"offerIdToken\": \"AUj\\/YhiCrZ\\/NvaFihCC8rjAWfXEsLtf\\/qPgutEo1M04GIc8psPY06GcWBpun6qf\\/NhMXcQe3KmD+rbgud2XiLO3ptF41\\/HWcHR7YfYcU7brJ6mM=\",\n" + + "\t\t\"basePlanId\": \"weekly\",\n" + + "\t\t\"pricingPhases\": [{\n" + + "\t\t\t\"priceAmountMicros\": 20000000,\n" + + "\t\t\t\"priceCurrencyCode\": \"INR\",\n" + + "\t\t\t\"formattedPrice\": \"₹20.00\",\n" + + "\t\t\t\"billingPeriod\": \"P1W\",\n" + + "\t\t\t\"recurrenceMode\": 1\n" + + "\t\t}],\n" + + "\t\t\"offerTags\": []\n" + + "\t}, {\n" + + "\t\t\"offerIdToken\": \"AUj\\/YhgOwTW\\/BAGR2Po8uAsNJc6G+Z5xSDRBnDU7VJ5GN21yhMvuUjUMFDNCwEu+GtDaN2CzYoLqu7wHu\\/T+37S1KlyLFi0tfSAZcJE5MisuY+hKUuRJ\",\n" + + "\t\t\"basePlanId\": \"monthly\",\n" + + "\t\t\"pricingPhases\": [{\n" + + "\t\t\t\"priceAmountMicros\": 40000000,\n" + + "\t\t\t\"priceCurrencyCode\": \"INR\",\n" + + "\t\t\t\"formattedPrice\": \"₹40.00\",\n" + + "\t\t\t\"billingPeriod\": \"P1M\",\n" + + "\t\t\t\"recurrenceMode\": 1\n" + + "\t\t}],\n" + + "\t\t\"offerTags\": []\n" + + "\t}]\n" + + "}") + return productDetails +} \ No newline at end of file diff --git a/chargebee/src/test/java/com/chargebee/android/billingservice/BillingClientManagerTest.kt b/chargebee/src/test/java/com/chargebee/android/billingservice/BillingClientManagerTest.kt index 03cf39c..700f81b 100644 --- a/chargebee/src/test/java/com/chargebee/android/billingservice/BillingClientManagerTest.kt +++ b/chargebee/src/test/java/com/chargebee/android/billingservice/BillingClientManagerTest.kt @@ -1,4 +1,4 @@ -package com.chargebee.android.resources +package com.chargebee.android.billingservice import android.content.Context import android.os.Build @@ -6,15 +6,19 @@ import androidx.test.core.app.ApplicationProvider import com.android.billingclient.api.* import com.chargebee.android.Chargebee import com.chargebee.android.ErrorDetail -import com.chargebee.android.billingservice.BillingClientManager -import com.chargebee.android.billingservice.CBCallback import com.chargebee.android.billingservice.CBCallback.ListProductsCallback -import com.chargebee.android.billingservice.CBPurchase import com.chargebee.android.exceptions.CBException import com.chargebee.android.exceptions.CBProductIDResult import com.chargebee.android.exceptions.ChargebeeResult +import com.chargebee.android.fixtures.otpProducts +import com.chargebee.android.fixtures.subProducts +import com.chargebee.android.models.CBNonSubscriptionResponse import com.chargebee.android.models.CBProduct +import com.chargebee.android.models.NonSubscription +import com.chargebee.android.models.PurchaseProductParams import com.chargebee.android.network.* +import com.chargebee.android.resources.CatalogVersion +import com.chargebee.android.resources.ReceiptResource import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -36,21 +40,31 @@ import kotlin.collections.ArrayList @RunWith(MockitoJUnitRunner::class) @Config(sdk = [Build.VERSION_CODES.LOLLIPOP]) -class BillingClientManagerTest { +class BillingClientManagerTest { private var billingClientManager: BillingClientManager? = null - @Mock - lateinit var billingClient: BillingClient @Mock - lateinit var skuDetails: SkuDetails + lateinit var billingClient: BillingClient private var mContext: Context? = null - private var callBack : ListProductsCallback>? = null - private var callBackPurchase : CBCallback.PurchaseCallback? = null + private var callBack: ListProductsCallback>? = null + private var callBackPurchase: CBCallback.PurchaseCallback? = null private val productIdList = arrayListOf("merchant.pro.android", "merchant.premium.android") - private var customer = CBCustomer("test","android","test","test@gmail.com") + private var customer = CBCustomer("test", "android", "test", "test@gmail.com") private var customerId: String = "test" + private val purchaseToken = "56sadmnagdjsd" + private val params = Params( + "purchaseToken", + "product.productId", + customer, + Chargebee.channel, + null + ) + private val receiptDetail = ReceiptDetail("subscriptionId", "customerId", "planId") + private var callBackOneTimePurchase: CBCallback.OneTimePurchaseCallback? = null + private val nonSubscriptionDetail = NonSubscription("invoiceId", "customerId", "chargeId") + private val productDetails = ProductDetails::class.create() @Before @@ -64,9 +78,7 @@ class BillingClientManagerTest { billingClientManager = callBack?.let { BillingClientManager( - ApplicationProvider.getApplicationContext(), - BillingClient.SkuType.SUBS, - productIdList, it + ApplicationProvider.getApplicationContext() ) } } @@ -84,7 +96,6 @@ class BillingClientManagerTest { val productIdList = arrayListOf("merchant.pro.android", "merchant.premium.android") CoroutineScope(Dispatchers.IO).launch { - val skuType = CBPurchase.SkuType.SUBS Mockito.`when`(mContext?.let { CBPurchase.retrieveProducts( it, @@ -113,7 +124,6 @@ class BillingClientManagerTest { @Test fun test_retrieveProducts_error(){ val productIdList = arrayListOf("") - CoroutineScope(Dispatchers.IO).launch { Mockito.`when`(mContext?.let { CBPurchase.retrieveProducts( @@ -143,7 +153,7 @@ class BillingClientManagerTest { val IDs = java.util.ArrayList() IDs.add("") CoroutineScope(Dispatchers.IO).launch { - Mockito.`when`(CBPurchase.retrieveProductIdentifers(queryParam) { + Mockito.`when`(CBPurchase.retrieveProductIdentifiers(queryParam) { when (it) { is CBProductIDResult.ProductIds -> { assertThat(it,instanceOf(CBProductIDResult::class.java)) @@ -227,7 +237,7 @@ class BillingClientManagerTest { val productsIds = java.util.ArrayList() productsIds.add("") CoroutineScope(Dispatchers.IO).launch { - Mockito.`when`(CBPurchase.retrieveProductIdentifers(queryParam) { + Mockito.`when`(CBPurchase.retrieveProductIdentifiers(queryParam) { when (it) { is CBProductIDResult.ProductIds -> { assertThat(it,instanceOf(CBProductIDResult::class.java)) @@ -242,13 +252,15 @@ class BillingClientManagerTest { } @Test fun test_purchaseProduct_success(){ - // val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" + val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" + + val skuDetails = SkuDetails(jsonDetails) - val products = CBProduct("","","", skuDetails,true) val lock = CountDownLatch(1) CoroutineScope(Dispatchers.IO).launch { + val purchaseProductParams = PurchaseProductParams(subProducts) CBPurchase.purchaseProduct( - products,"", + purchaseProductParams,null, object : CBCallback.PurchaseCallback { override fun onError(error: CBException) { lock.countDown() @@ -262,23 +274,23 @@ class BillingClientManagerTest { }) Mockito.`when`(callBackPurchase?.let { - billingClientManager?.purchase(products, it) + billingClientManager?.purchase(purchaseProductParams, it) }).thenReturn(Unit) callBackPurchase?.let { - verify(billingClientManager, times(1))?.purchase(products,purchaseCallBack = it) + verify(billingClientManager, times(1))?.purchase(purchaseProductParams,purchaseCallBack = it) } } lock.await() - } @Test fun test_purchaseProduct_error(){ val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" - val products = CBProduct("","","", skuDetails,true) + val skuDetails = SkuDetails(jsonDetails) CoroutineScope(Dispatchers.IO).launch { + val purchaseProductParams = PurchaseProductParams(subProducts) CBPurchase.purchaseProduct( - products,"", + purchaseProductParams,null, object : CBCallback.PurchaseCallback { override fun onSuccess(result: ReceiptDetail, status: Boolean) { @@ -294,12 +306,9 @@ class BillingClientManagerTest { @Test fun test_validateReceipt_success(){ val purchaseToken = "56sadmnagdjsd" - val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" - - val products = CBProduct("merchant.premium.android","Premium Plan (Chargebee Example)","₹2,650.00", skuDetails,true) val lock = CountDownLatch(1) CoroutineScope(Dispatchers.IO).launch { - CBPurchase.validateReceipt(purchaseToken, products) { + CBPurchase.validateReceipt(purchaseToken, "productId") { when (it) { is ChargebeeResult.Success -> { lock.countDown() @@ -313,16 +322,7 @@ class BillingClientManagerTest { } } lock.await() - - val params = Params( - purchaseToken, - products.productId, - customer, - Chargebee.channel - ) - val receiptDetail = ReceiptDetail("subscriptionId","customerId","planId") val response = CBReceiptResponse(receiptDetail) - CoroutineScope(Dispatchers.IO).launch { Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( ChargebeeResult.Success( @@ -330,18 +330,14 @@ class BillingClientManagerTest { ) ) verify(ReceiptResource(), times(1)).validateReceipt(params) - verify(CBReceiptRequestBody("receipt","",null,""), times(1)).toCBReceiptReqBody() + verify(CBReceiptRequestBody("receipt","",null,"", null), times(1)).toCBReceiptReqBody() } } @Test fun test_validateReceipt_error(){ val purchaseToken = "56sadmnagdjsd" - val jsonDetails = "{\"productId\":\"merchant.premium.test.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" - - // val skuDetails: SkuDetails? = null - val products = CBProduct("merchant.premium.test.android","Premium Plan (Chargebee Example)","₹2,650.00", skuDetails,true) CoroutineScope(Dispatchers.IO).launch { - CBPurchase.validateReceipt(purchaseToken, products) { + CBPurchase.validateReceipt(purchaseToken, "products") { when (it) { is ChargebeeResult.Success -> { assertThat(it, instanceOf(CBReceiptResponse::class.java)) @@ -352,13 +348,6 @@ class BillingClientManagerTest { } } } - - val params = Params( - purchaseToken, - products.productId, - customer, - Chargebee.channel - ) val exception = CBException(ErrorDetail("Error")) CoroutineScope(Dispatchers.IO).launch { Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( @@ -367,18 +356,21 @@ class BillingClientManagerTest { ) ) verify(ReceiptResource(), times(1)).validateReceipt(params) - verify(CBReceiptRequestBody("receipt","",null,""), times(1)).toCBReceiptReqBody() + verify(CBReceiptRequestBody("receipt","",null,"", null), times(1)).toCBReceiptReqBody() } } @Test fun test_purchaseProductWithEmptyCBCustomer_success(){ val customer = CBCustomer("","","","") - val products = CBProduct("","","", skuDetails,true) + val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" + + val skuDetails = SkuDetails(jsonDetails) val lock = CountDownLatch(1) CoroutineScope(Dispatchers.IO).launch { + val purchaseProductParams = PurchaseProductParams(subProducts) CBPurchase.purchaseProduct( - products,customer, + purchaseProductParams,customer, object : CBCallback.PurchaseCallback { override fun onError(error: CBException) { lock.countDown() @@ -392,10 +384,10 @@ class BillingClientManagerTest { }) Mockito.`when`(callBackPurchase?.let { - billingClientManager?.purchase(products, it) + billingClientManager?.purchase(purchaseProductParams, it) }).thenReturn(Unit) callBackPurchase?.let { - verify(billingClientManager, times(1))?.purchase(products,purchaseCallBack = it) + verify(billingClientManager, times(1))?.purchase(purchaseProductParams,purchaseCallBack = it) } } lock.await() @@ -403,11 +395,14 @@ class BillingClientManagerTest { @Test fun test_purchaseProductWithCBCustomer_success(){ - val products = CBProduct("","","", skuDetails,true) + val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" + + val skuDetails = SkuDetails(jsonDetails) val lock = CountDownLatch(1) CoroutineScope(Dispatchers.IO).launch { + val purchaseProductParams = PurchaseProductParams(subProducts) CBPurchase.purchaseProduct( - products,customer, + purchaseProductParams,customer, object : CBCallback.PurchaseCallback { override fun onError(error: CBException) { lock.countDown() @@ -421,20 +416,23 @@ class BillingClientManagerTest { }) Mockito.`when`(callBackPurchase?.let { - billingClientManager?.purchase(products, it) + billingClientManager?.purchase(purchaseProductParams, it) }).thenReturn(Unit) callBackPurchase?.let { - verify(billingClientManager, times(1))?.purchase(products,purchaseCallBack = it) + verify(billingClientManager, times(1))?.purchase(purchaseProductParams,purchaseCallBack = it) } } lock.await() } @Test fun test_purchaseProductWithCBCustomer_error(){ - val products = CBProduct("","","", skuDetails,true) + val jsonDetails = "{\"productId\":\"merchant.premium.android\",\"type\":\"subs\",\"title\":\"Premium Plan (Chargebee Example)\",\"name\":\"Premium Plan\",\"price\":\"₹2,650.00\",\"price_amount_micros\":2650000000,\"price_currency_code\":\"INR\",\"description\":\"Every 6 Months\",\"subscriptionPeriod\":\"P6M\",\"skuDetailsToken\":\"AEuhp4J0KiD1Bsj3Yq2mHPBRNHUBdzs4nTJY3PWRR8neE-22MJNssuDzH2VLFKv35Ov8\"}" + + val skuDetails = SkuDetails(jsonDetails) CoroutineScope(Dispatchers.IO).launch { + val purchaseProductParams = PurchaseProductParams(subProducts) CBPurchase.purchaseProduct( - products,customer, + purchaseProductParams,customer, object : CBCallback.PurchaseCallback { override fun onSuccess(result: ReceiptDetail, status: Boolean) { @@ -447,4 +445,186 @@ class BillingClientManagerTest { }) } } + + @Test + fun test_validateReceiptWithChargebee_success() { + val response = CBReceiptResponse(receiptDetail) + CoroutineScope(Dispatchers.IO).launch { + CBPurchase.validateReceipt( + purchaseToken = "purchaseToken", + productId = "productId" + ) { + when (it) { + is ChargebeeResult.Success -> { + assertThat(it, instanceOf(ReceiptDetail::class.java)) + } + is ChargebeeResult.Error -> { + println(" Error : ${it.exp}") + } + } + } + } + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( + ChargebeeResult.Success( + response + ) + ) + verify(ReceiptResource(), times(1)).validateReceipt(params) + verify(CBReceiptRequestBody("receipt", "", null, "", null), times(1)).toCBReceiptReqBody() + + } + } + + @Test + fun test_validateReceiptWithChargebee_error() { + val exception = CBException(ErrorDetail("Error")) + CoroutineScope(Dispatchers.IO).launch { + CBPurchase.validateReceipt( + purchaseToken = "purchaseToken", + productId = "productId" + ) { + when (it) { + is ChargebeeResult.Success -> { + assertThat(it, instanceOf(ReceiptDetail::class.java)) + } + is ChargebeeResult.Error -> { + println(" Error : ${it.exp}") + } + } + } + } + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( + ChargebeeResult.Error( + exception + ) + ) + verify(ReceiptResource(), times(1)).validateReceipt(params) + verify(CBReceiptRequestBody("receipt", "", null, "", null), times(1)).toCBReceiptReqBody() + } + } + + @Test + fun test_purchaseNonSubscriptionProduct_success(){ + val lock = CountDownLatch(1) + CoroutineScope(Dispatchers.IO).launch { + CBPurchase.purchaseNonSubscriptionProduct( + product = otpProducts, + productType = OneTimeProductType.CONSUMABLE, + callback = object : CBCallback.OneTimePurchaseCallback { + override fun onError(error: CBException) { + lock.countDown() + assertThat(error, instanceOf(CBException::class.java)) + println(" Error : ${error.message} response code: ${error.httpStatusCode}") + } + + override fun onSuccess(result: NonSubscription, status: Boolean) { + lock.countDown() + assertThat(result, instanceOf(NonSubscription::class.java)) + } + }) + + Mockito.`when`(callBackOneTimePurchase?.let { + billingClientManager?.purchaseNonSubscriptionProduct(otpProducts, it) + }).thenReturn(Unit) + callBackOneTimePurchase?.let { + verify(billingClientManager, times(1))?.purchaseNonSubscriptionProduct( + otpProducts, + oneTimePurchaseCallback = it + ) + } + } + lock.await() + } + + @Test + fun test_purchaseNonSubscriptionProduct_error(){ + val lock = CountDownLatch(1) + CoroutineScope(Dispatchers.IO).launch { + CBPurchase.purchaseNonSubscriptionProduct( + product = otpProducts, + productType = OneTimeProductType.CONSUMABLE, + callback = object : CBCallback.OneTimePurchaseCallback { + override fun onError(error: CBException) { + lock.countDown() + assertThat(error, instanceOf(CBException::class.java)) + } + + override fun onSuccess(result: NonSubscription, status: Boolean) { + lock.countDown() + assertThat(result, instanceOf(NonSubscription::class.java)) + } + }) + + Mockito.`when`(callBackOneTimePurchase?.let { + billingClientManager?.purchaseNonSubscriptionProduct(otpProducts, it) + }).thenReturn(Unit) + callBackOneTimePurchase?.let { + verify(billingClientManager, times(1))?.purchaseNonSubscriptionProduct( + otpProducts, + oneTimePurchaseCallback = it + ) + } + } + lock.await() + } + + @Test + fun test_validateNonSubscriptionReceipt_success(){ + val purchaseToken = "56sadmnagdjsd" + val lock = CountDownLatch(1) + CoroutineScope(Dispatchers.IO).launch { + CBPurchase.validateNonSubscriptionReceipt(purchaseToken, "productId") { + when (it) { + is ChargebeeResult.Success -> { + lock.countDown() + assertThat(it, instanceOf(CBNonSubscriptionResponse::class.java)) + } + is ChargebeeResult.Error -> { + lock.countDown() + println(" Error : ${it.exp.message}") + } + } + } + } + lock.await() + val response = CBNonSubscriptionResponse(nonSubscriptionDetail) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceiptForNonSubscription(it) }).thenReturn( + ChargebeeResult.Success( + response + ) + ) + verify(ReceiptResource(), times(1)).validateReceiptForNonSubscription(params) + verify(CBReceiptRequestBody("receipt","",null,"", null), times(1)).toMapNonSubscription() + } + } + + @Test + fun test_validateNonSubscriptionReceipt_error(){ + val purchaseToken = "56sadmnagdjsd" + CoroutineScope(Dispatchers.IO).launch { + CBPurchase.validateNonSubscriptionReceipt(purchaseToken, "products") { + when (it) { + is ChargebeeResult.Success -> { + assertThat(it, instanceOf(CBNonSubscriptionResponse::class.java)) + } + is ChargebeeResult.Error -> { + println(" Error : ${it.exp.message} response code: ${it.exp.httpStatusCode}") + } + } + } + } + val exception = CBException(ErrorDetail("Error")) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceiptForNonSubscription(it) }).thenReturn( + ChargebeeResult.Error( + exception + ) + ) + verify(ReceiptResource(), times(1)).validateReceiptForNonSubscription(params) + verify(CBReceiptRequestBody("receipt","",null,"", null), times(1)).toMapNonSubscription() + } + } } \ No newline at end of file diff --git a/chargebee/src/test/java/com/chargebee/android/fixtures/ProductFixtures.kt b/chargebee/src/test/java/com/chargebee/android/fixtures/ProductFixtures.kt new file mode 100644 index 0000000..f25002d --- /dev/null +++ b/chargebee/src/test/java/com/chargebee/android/fixtures/ProductFixtures.kt @@ -0,0 +1,15 @@ +package com.chargebee.android.fixtures + +import com.chargebee.android.billingservice.ProductType +import com.chargebee.android.models.CBProduct +import com.chargebee.android.models.PricingPhase +import com.chargebee.android.models.SubscriptionOffer + +val subsPricingPhase: PricingPhase = PricingPhase(formattedPrice = "1100.0 INR", amountInMicros = 1100000, currencyCode = "INR") +val subscriptionOffers: List = arrayListOf(SubscriptionOffer("basePlanId", "offerId", "offerToken", arrayListOf(subsPricingPhase))) +val oneTimePurchaseOffer: PricingPhase = PricingPhase(formattedPrice = "100.0 INR", amountInMicros = 100000, currencyCode = "INR") +val otpProducts = CBProduct("test.consumable","Example product", + "Description",ProductType.INAPP,null, oneTimePurchaseOffer) +val subProducts = CBProduct("chargebee.premium.android","Premium Plan", + "Description",ProductType.SUBS, subscriptionOffers, null) + diff --git a/chargebee/src/test/java/com/chargebee/android/resources/ItemResourceTest.kt b/chargebee/src/test/java/com/chargebee/android/resources/ItemResourceTest.kt index 2f85655..a987277 100644 --- a/chargebee/src/test/java/com/chargebee/android/resources/ItemResourceTest.kt +++ b/chargebee/src/test/java/com/chargebee/android/resources/ItemResourceTest.kt @@ -32,7 +32,9 @@ class ItemResourceTest { site = "cb-imay-test", publishableApiKey = "test_EojsGoGFeHoc3VpGPQDOZGAxYy3d0FF3", sdkKey = "cb-j53yhbfmtfhfhkmhow3ramecom" - ) + ) { + + } } @After @@ -42,10 +44,14 @@ class ItemResourceTest { @Test fun test_retrieveItemsList_success(){ - val item = Items("123","item","active","play_store") + val item = Items( + "id", "name", "invoice", "play_store", "123", "", 0, + "12", "23", false, "false", false, false, "false", + false, "false" + ) val queryParam = arrayOf("Standard", "app_store") val lock = CountDownLatch(1) - Items.retrieveAllItems(queryParam) { + Chargebee.retrieveAllItems(queryParam) { when (it) { is ChargebeeResult.Success -> { lock.countDown() @@ -77,7 +83,7 @@ class ItemResourceTest { val exception = CBException(ErrorDetail("Error")) val queryParam = arrayOf("Standard", "app_store") val lock = CountDownLatch(1) - Items.retrieveAllItems(queryParam) { + Chargebee.retrieveAllItems(queryParam) { when (it) { is ChargebeeResult.Success -> { lock.countDown() @@ -106,15 +112,15 @@ class ItemResourceTest { } @Test fun test_retrieveItem_success(){ - val plan = Plan( - "id", "name", "invoice", 123, 123, "", "", - 12, 23, "", false, false, "false", false, - 9, false, "app_store", 7, "", "", false, "", false, false + val item = Items( + "id", "name", "invoice", "play_store", "123", "", 0, + "12", "23", false, "false", false, false, "false", + false, "false" ) val queryParam = "Standard" val lock = CountDownLatch(1) - Items.retrieveItem(queryParam) { + Chargebee.retrieveItem(queryParam) { when (it) { is ChargebeeResult.Success -> { lock.countDown() @@ -135,7 +141,7 @@ class ItemResourceTest { CoroutineScope(Dispatchers.IO).launch { Mockito.`when`(ItemsResource().retrieveItem(queryParam)).thenReturn( ChargebeeResult.Success( - plan + item ) ) Mockito.verify(ItemsResource(), Mockito.times(1)).retrieveItem(queryParam) @@ -146,7 +152,7 @@ class ItemResourceTest { val exception = CBException(ErrorDetail("Error")) val queryParam = "Standard" val lock = CountDownLatch(1) - Items.retrieveItem(queryParam) { + Chargebee.retrieveItem(queryParam) { when (it) { is ChargebeeResult.Success -> { lock.countDown() diff --git a/chargebee/src/test/java/com/chargebee/android/resources/SubscriptionResourceTest.kt b/chargebee/src/test/java/com/chargebee/android/resources/SubscriptionResourceTest.kt index 7a943a4..cc26bcb 100644 --- a/chargebee/src/test/java/com/chargebee/android/resources/SubscriptionResourceTest.kt +++ b/chargebee/src/test/java/com/chargebee/android/resources/SubscriptionResourceTest.kt @@ -41,10 +41,10 @@ class SubscriptionResourceTest { fun test_subscriptionStatus_success(){ val subscriptionDetail = SubscriptionDetail("123","item","active","","", - "") + "","","") val queryParam = "0000987657" val lock = CountDownLatch(1) - SubscriptionDetail.retrieveSubscription(queryParam) { + Chargebee.retrieveSubscription(queryParam) { when (it) { is ChargebeeResult.Success -> { lock.countDown() @@ -76,7 +76,7 @@ class SubscriptionResourceTest { val exception = CBException(ErrorDetail("Error")) val queryParam = "0000987657" val lock = CountDownLatch(1) - SubscriptionDetail.retrieveSubscription(queryParam) { + Chargebee.retrieveSubscription(queryParam) { when (it) { is ChargebeeResult.Success -> { lock.countDown() diff --git a/chargebee/src/test/java/com/chargebee/android/restore/RestorePurchaseTest.kt b/chargebee/src/test/java/com/chargebee/android/restore/RestorePurchaseTest.kt new file mode 100644 index 0000000..38a6c60 --- /dev/null +++ b/chargebee/src/test/java/com/chargebee/android/restore/RestorePurchaseTest.kt @@ -0,0 +1,268 @@ +package com.chargebee.android.restore + +import com.android.billingclient.api.* +import com.chargebee.android.Chargebee +import com.chargebee.android.ErrorDetail +import com.chargebee.android.billingservice.CBCallback.RestorePurchaseCallback +import com.chargebee.android.exceptions.CBException +import com.chargebee.android.exceptions.ChargebeeResult +import com.chargebee.android.models.* +import com.chargebee.android.network.* +import com.chargebee.android.resources.ReceiptResource +import com.chargebee.android.resources.RestorePurchaseResource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import org.hamcrest.MatcherAssert +import org.hamcrest.Matchers +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.any +import org.mockito.Mockito +import org.mockito.MockitoAnnotations +import org.mockito.junit.MockitoJUnitRunner +import java.util.concurrent.CountDownLatch + +@RunWith(MockitoJUnitRunner::class) +class RestorePurchaseTest { + private var allTransactions = ArrayList() + private var restorePurchases = ArrayList() + private var activeTransactions = ArrayList() + private var customer: CBCustomer? = null + private val list = ArrayList() + private val storeTransactions = arrayListOf() + private val lock = CountDownLatch(1) + private val response = + CBReceiptResponse(ReceiptDetail("subscriptionId", "customerId", "planId")) + private val error = CBException( + ErrorDetail( + message = "The Token data sent is not correct or Google service is temporarily down", + httpStatusCode = 400 + ) + ) + + @Before + fun setUp() { + MockitoAnnotations.initMocks(this) + customer = CBCustomer("test", "android", "test", "test@gmail.com") + list.add("chargebee.pro.android") + Chargebee.configure( + site = "omni1-test.integrations.predev37.in", + publishableApiKey = "test_rpKneFyplowONFtdHgnlpxh6ccdcQXNUcu", + sdkKey = "cb-hmg6jlyvyrahvocyio57oqhoei", + packageName = "com.chargebee.example" + ) + } + + @After + fun tearDown() { + allTransactions.clear() + restorePurchases.clear() + activeTransactions.clear() + customer = null + } + + @Test + fun test_fetchStoreSubscriptionStatus_success() { + val lock = CountDownLatch(1) + val purchaseTransaction = getTransaction(true) + + CBRestorePurchaseManager.fetchStoreSubscriptionStatus( + purchaseTransaction, + purchaseTransaction, + purchaseTransaction, + arrayListOf(), + completionCallback = object : RestorePurchaseCallback { + override fun onSuccess(result: List) { + lock.countDown() + result.forEach { + MatcherAssert.assertThat( + (it), + Matchers.instanceOf(CBRestoreSubscription::class.java) + ) + } + + } + + override fun onError(error: CBException) { + lock.countDown() + MatcherAssert.assertThat( + error, + Matchers.instanceOf(CBException::class.java) + ) + } + }) + lock.await() + } + + @Test + fun test_fetchStoreSubscriptionStatus_failure() { + val purchaseTransaction = getTransaction(false) + + val storeTransaction = + purchaseTransaction.firstOrNull()?.also { purchaseTransaction.remove(it) } + storeTransaction?.purchaseToken?.let { purchaseToken -> + CBRestorePurchaseManager.retrieveRestoreSubscription(purchaseToken, {}, { error -> + lock.countDown() + MatcherAssert.assertThat( + (error), + Matchers.instanceOf(CBException::class.java) + ) + Mockito.verify(CBRestorePurchaseManager, Mockito.times(1)) + .getRestorePurchases(any(), any(), any(), any()) + }) + } + lock.await() + } + + @Test + fun test_retrieveStoreSubscription_success() { + val purchaseTransaction = getTransaction(true) + val cbRestorePurchasesList = arrayListOf() + val purchaseToken = purchaseTransaction.first().purchaseToken + val cbRestoreSubscription = CBRestoreSubscription("", "", StoreStatus.Active.value) + cbRestorePurchasesList.add(cbRestoreSubscription) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(RestorePurchaseResource().retrieveStoreSubscription(purchaseToken)) + .thenReturn( + ChargebeeResult.Success( + CBRestorePurchases(cbRestorePurchasesList) + ) + ) + Mockito.verify(RestorePurchaseResource(), Mockito.times(1)) + .retrieveStoreSubscription(purchaseToken) + } + } + + @Test + fun test_retrieveStoreSubscription_failure() { + val purchaseTransaction = getTransaction(false) + val purchaseToken = purchaseTransaction.first().purchaseToken + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(RestorePurchaseResource().retrieveStoreSubscription(purchaseToken)) + .thenReturn( + ChargebeeResult.Error( + error + ) + ) + Mockito.verify(RestorePurchaseResource(), Mockito.times(1)) + .retrieveStoreSubscription(purchaseToken) + } + } + + @Test + fun test_validateReceipt_success() { + val purchaseTransaction = getTransaction(true) + val params = Params( + purchaseTransaction.first().purchaseToken, + purchaseTransaction.first().productId.first(), + customer, + Chargebee.channel, + null + ) + CBRestorePurchaseManager.validateReceipt( + params.receipt, + purchaseTransaction.first().productType + ) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( + ChargebeeResult.Success( + response + ) + ) + Mockito.verify(ReceiptResource(), Mockito.times(1)).validateReceipt(params) + Mockito.verify(CBReceiptRequestBody("receipt", "", null, "", null), Mockito.times(1)) + .toCBReceiptReqBody() + } + } + + @Test + fun test_validateReceipt_failure() { + val purchaseTransaction = getTransaction(false) + val params = Params( + purchaseTransaction.first().purchaseToken, + purchaseTransaction.first().productId.first(), + customer, + Chargebee.channel, + null + ) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( + ChargebeeResult.Error( + error + ) + ) + Mockito.verify(ReceiptResource(), Mockito.times(1)).validateReceipt(params) + Mockito.verify(CBReceiptRequestBody("receipt", "", null, "", null), Mockito.times(1)) + .toCBReceiptReqBody() + } + } + + @Test + fun test_syncPurchaseWithChargebee_success() { + val purchaseTransaction = getTransaction(false) + val params = Params( + purchaseTransaction.first().purchaseToken, + purchaseTransaction.first().productId.first(), + customer, + Chargebee.channel, + null + ) + CBRestorePurchaseManager.syncPurchaseWithChargebee(purchaseTransaction) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( + ChargebeeResult.Success( + response + ) + ) + Mockito.verify(ReceiptResource(), Mockito.times(1)).validateReceipt(params) + Mockito.verify(CBReceiptRequestBody("receipt", "", null, "", null), Mockito.times(1)) + .toCBReceiptReqBody() + } + } + + @Test + fun test_syncPurchaseWithChargebee_failure() { + val purchaseTransaction = getTransaction(false) + val params = Params( + purchaseTransaction.first().purchaseToken, + purchaseTransaction.first().productId.first(), + customer, + Chargebee.channel, + null + ) + CBRestorePurchaseManager.syncPurchaseWithChargebee(purchaseTransaction) + CoroutineScope(Dispatchers.IO).launch { + Mockito.`when`(params.let { ReceiptResource().validateReceipt(it) }).thenReturn( + ChargebeeResult.Error( + error + ) + ) + Mockito.verify(ReceiptResource(), Mockito.times(1)).validateReceipt(params) + Mockito.verify(CBReceiptRequestBody("receipt", "", null, "", null), Mockito.times(1)) + .toCBReceiptReqBody() + } + } + + private fun getTransaction(isTestingSuccess: Boolean): ArrayList { + storeTransactions.clear() + val result = if (isTestingSuccess) + PurchaseTransaction( + productId = list.toList(), + purchaseTime = 1682666112774, + purchaseToken = "fajeooclbamgohgapjeehghm.AO-J1OzxVvoEx7y53c9DsypEKwgcfGw2OrisyQsQ-MG6KiXfJ97nT33Yd5VpbQYxd225QnTAEVdPuLP4YSvZE6LBhsv1rzSlizuBxBTjBWghWguSBBtgp2g", + productType = "subs" + ) + else + PurchaseTransaction( + productId = list.toList(), + purchaseTime = 1682666112774, + purchaseToken = "test data", + productType = "subs" + ) + storeTransactions.add(result) + return storeTransactions + } +} \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 6c9a224..2e6e589 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists