package com.jeremybarbet.reactnativepurchasekit import android.app.Activity import com.android.billingclient.api.AcknowledgePurchaseParams import com.android.billingclient.api.BillingClient import com.android.billingclient.api.BillingClient.BillingResponseCode import com.android.billingclient.api.BillingClient.ProductType import com.android.billingclient.api.BillingClientStateListener import com.android.billingclient.api.BillingFlowParams import com.android.billingclient.api.BillingResult import com.android.billingclient.api.ConsumeParams import com.android.billingclient.api.ProductDetails import com.android.billingclient.api.ProductDetailsResult import com.android.billingclient.api.Purchase import com.android.billingclient.api.PurchaseHistoryRecord import com.android.billingclient.api.PurchasesUpdatedListener import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchaseHistoryParams import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.queryProductDetails import com.android.billingclient.api.queryPurchasesAsync import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.bridge.ReactContextBaseJavaModule import com.facebook.react.bridge.ReactMethod import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReadableMap enum class State { CONNECTED, DISCONNECTED, } class PurchaseKitModule(reactContext: ReactApplicationContext) : ReactContextBaseJavaModule(reactContext) { override fun getName() = "PurchaseKit" private val scope = CoroutineScope(Job() + Dispatchers.IO) private var job: Job? = null private var promise: Promise? = null private var state = State.DISCONNECTED private val availableProducts: MutableList = mutableListOf() private val purchasedProducts: MutableSet = mutableSetOf() private fun responseCodeToString(responseCode: Int, fallbackMessage: String? = null): String { return when (responseCode) { BillingResponseCode.FEATURE_NOT_SUPPORTED -> "Feature not supported" BillingResponseCode.SERVICE_DISCONNECTED -> "Service disconnected" BillingResponseCode.OK -> "Operation successful" BillingResponseCode.USER_CANCELED -> "User canceled" BillingResponseCode.SERVICE_UNAVAILABLE -> "Service unavailable" BillingResponseCode.BILLING_UNAVAILABLE -> "Billing unavailable" BillingResponseCode.ITEM_UNAVAILABLE -> "Item unavailable" BillingResponseCode.DEVELOPER_ERROR -> "Developer error" BillingResponseCode.ERROR -> "Generic error" BillingResponseCode.ITEM_ALREADY_OWNED -> "Item already owned" BillingResponseCode.ITEM_NOT_OWNED -> "Item not owned" BillingResponseCode.NETWORK_ERROR -> "Network error" else -> fallbackMessage ?: "Unknown error" } } private fun hasAvailableProduct(id: String): Boolean { return availableProducts.any { availableProduct -> availableProduct.productId == id } } private fun getAvailableProduct(id: String): ProductDetails? { return availableProducts.find { availableProduct -> availableProduct.productId == id } } private fun getPurchasedProduct(token: String): Purchase? { return purchasedProducts.find { purchasedProduct -> purchasedProduct.purchaseToken == token } } private fun getProductType(type: String?): String { return if (type == "subscription") { ProductType.SUBS } else { ProductType.INAPP } } private fun checkState(promise: Promise) { if (state == State.DISCONNECTED) { promise.reject(ErrorCode.FAILED.toString(), "BillingClient is not available") } } private suspend fun fetchProductDetails(ids: Array, type: String?): ProductDetailsResult { val list = ArrayList() val productType = getProductType(type) for (id in ids) { if (id != null) { list.add( QueryProductDetailsParams .Product .newBuilder() .setProductId(id) .setProductType(productType) .build() ) } } val params = QueryProductDetailsParams .newBuilder() .setProductList(list) .build() return withContext(Dispatchers.IO) { billingClient.queryProductDetails(params) } } private val purchasesUpdatedListener = PurchasesUpdatedListener { billingResult, purchases -> if (!purchases.isNullOrEmpty() && billingResult.responseCode == BillingResponseCode.OK) { val purchase = purchases[0] val productId = purchase.products[0] val availableProduct = getAvailableProduct(productId) val type = if (availableProduct?.subscriptionOfferDetails?.isNotEmpty() == true) { "subscription" } else { "consumable" } val price = if (availableProduct != null) { parsePrice(availableProduct, type) } else { PurchaseKitPrice( 0.0, "", "", ) } val parsedTransaction = parseTransaction(purchase, productId, price) val transaction = encodeTransaction(parsedTransaction) if (!purchasedProducts.contains(purchase)) { purchasedProducts.add(purchase) } promise?.resolve(transaction) } else if (billingResult.responseCode == BillingResponseCode.ITEM_ALREADY_OWNED) { val message = responseCodeToString(billingResult.responseCode) promise?.reject(ErrorCode.CANCELLED.toString(), message) } else if (billingResult.responseCode == BillingResponseCode.USER_CANCELED) { val message = responseCodeToString(billingResult.responseCode) promise?.reject(ErrorCode.CANCELLED.toString(), message) } else { val message = responseCodeToString(billingResult.responseCode, "An error happened when purchasing this product") promise?.reject(ErrorCode.FAILED.toString(), message) } } private var billingClient = BillingClient .newBuilder(this.reactApplicationContext) .setListener(purchasesUpdatedListener) .enablePendingPurchases() .build() @ReactMethod override fun initialize() { billingClient.startConnection(object : BillingClientStateListener { override fun onBillingSetupFinished(billingResult: BillingResult) { state = if (billingResult.responseCode == BillingResponseCode.OK) { State.CONNECTED } else { State.DISCONNECTED } } override fun onBillingServiceDisconnected() { state = State.DISCONNECTED } }) } @ReactMethod fun products(input: ReadableMap, promise: Promise) { checkState(promise) val idsArray = input.getArray("ids")!! val ids = Array(idsArray.size()) { idsArray.getString(it) } val type = input.getString("type") runBlocking { job = scope.launch { val result = fetchProductDetails(ids, type) val products = Arguments.createArray() result.productDetailsList?.forEach { productDetails -> val parsedProduct = parseProduct(productDetails, type) val product = encodeProduct(parsedProduct) if (!availableProducts.contains(productDetails)) { availableProducts.add(productDetails) } products.pushMap(product) } promise.resolve(products) } } } @ReactMethod fun entitlements(promise: Promise) { checkState(promise) runBlocking { job = scope.launch { val transactions = Arguments.createArray() val queryPurchasesParams = QueryPurchasesParams .newBuilder() .setProductType(ProductType.SUBS) .build() val result = billingClient.queryPurchasesAsync(queryPurchasesParams) result.purchasesList.forEach { purchase -> val productId = purchase.products[0] /** * We don't need to return the price through the transaction * for the entitlement, we populate it with empty data. */ val price = PurchaseKitPrice( 0.0, "", "", ) val parsedTransaction = parseTransaction(purchase, productId, price) val transaction = encodeTransaction(parsedTransaction) transactions.pushMap(transaction) } promise.resolve(transactions) } } } @ReactMethod fun purchase(input: ReadableMap, promise: Promise) { checkState(promise) val id = input.getString("id") val userId = input.getString("userId") val type = input.getString("type") val isSubscription = type == "subscription" if (id != null && userId != null && type != null) { // Assign the promise to the class property this.promise = promise runBlocking { job = scope.launch { val activity: Activity? = reactApplicationContext.currentActivity val products = fetchProductDetails(arrayOf(id), type) if (products.productDetailsList?.isNotEmpty() == true) { val details = products.productDetailsList!![0] val productDetails = getAvailableProduct(id) val offerToken = if (productDetails?.subscriptionOfferDetails?.isNotEmpty() == true) { productDetails.subscriptionOfferDetails!![0].offerToken } else { null } val productDetailsFlowParams = BillingFlowParams .ProductDetailsParams .newBuilder() .setProductDetails(details) if (isSubscription && offerToken?.isEmpty() == true) { promise.reject(ErrorCode.NOT_FOUND.toString(), "Subscription details couldn't be found") } if (isSubscription && offerToken != null) { productDetailsFlowParams.setOfferToken(offerToken) } val productDetailsParamsList = listOf(productDetailsFlowParams.build()) val billingFlowParams = BillingFlowParams .newBuilder() .setProductDetailsParamsList(productDetailsParamsList) .setObfuscatedAccountId(userId) .setObfuscatedProfileId(userId) .build() if (activity != null) { val result = billingClient.launchBillingFlow(activity, billingFlowParams) if (result.responseCode != BillingResponseCode.OK) { promise.reject(ErrorCode.FAILED.toString(), result.debugMessage) } } else { promise.reject(ErrorCode.FAILED.toString(), "Activity is null") } } else { promise.reject(ErrorCode.EMPTY.toString(), "Products is empty") } } } } else { promise.reject(ErrorCode.INPUT_VALIDATION.toString(), "Missing id, userId or type") } } @ReactMethod fun finishTransaction(input: ReadableMap, promise: Promise) { checkState(promise) val id = input.getString("id") val type = input.getString("type") val isSubscription = type == "subscription" if (id != null && type != null) { if (isSubscription) { val acknowledgePurchaseParams = AcknowledgePurchaseParams .newBuilder() .setPurchaseToken(id) .build() billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult: BillingResult -> if (billingResult.responseCode != BillingResponseCode.OK) { promise.reject(ErrorCode.FAILED.toString(), "Transaction couldn't be finished") } promise.resolve(null) } } else { val consumeParams = ConsumeParams .newBuilder() .setPurchaseToken(id) .build() billingClient.consumeAsync(consumeParams) { billingResult: BillingResult, _: String? -> if (billingResult.responseCode != BillingResponseCode.OK) { promise.reject(ErrorCode.FAILED.toString(), "Transaction couldn't be finished") } promise.resolve(null) } } val purchasedProduct = getPurchasedProduct(id) purchasedProducts.remove(purchasedProduct) } else { promise.reject(ErrorCode.INPUT_VALIDATION.toString(), "Missing id or type") } } @ReactMethod fun syncPurchases(promise: Promise) { checkState(promise) val queryPurchaseHistoryAsync = QueryPurchaseHistoryParams .newBuilder() .setProductType(ProductType.SUBS) .setProductType(ProductType.INAPP) .build() billingClient.queryPurchaseHistoryAsync(queryPurchaseHistoryAsync) { billingResult: BillingResult, purchaseHistoryRecordList: MutableList? -> if (billingResult.responseCode != BillingResponseCode.OK) { val message = responseCodeToString(billingResult.responseCode, "Couldn't sync purchases") promise.reject(ErrorCode.FAILED.toString(), message) } purchaseHistoryRecordList?.forEach { purchaseHistoryRecord -> runBlocking { job = scope.launch { val productId = purchaseHistoryRecord.products[0] val hasProduct = hasAvailableProduct(productId) // TODO: What if the product is not available, will the purchase // be synced with the QueryPurchasesParams method anyway? if (!hasProduct) { val result = fetchProductDetails(arrayOf(productId), ProductType.INAPP) val productDetails = result.productDetailsList!![0] availableProducts.add(productDetails) } } } } promise.resolve(true) } } }