feat: support Android IAP (#2286)
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run

This commit is contained in:
Huang Xin 2025-10-22 00:24:50 +08:00 committed by GitHub
parent 28ef414c3d
commit 8737535b90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1439 additions and 93 deletions

View file

@ -0,0 +1,283 @@
package com.readest.native_bridge
import android.app.Activity
import android.content.Context
import android.util.Log
import com.android.billingclient.api.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.SimpleDateFormat
import java.util.*
class BillingManager(private val activity: Activity) : PurchasesUpdatedListener {
private lateinit var billingClient: BillingClient
private val productsCache = mutableMapOf<String, ProductDetails>()
private var purchaseCallback: ((PurchaseData?) -> Unit)? = null
private val scope = CoroutineScope(Dispatchers.Main)
companion object {
private const val TAG = "BillingManager"
}
fun initialize(callback: (Boolean) -> Unit) {
billingClient = BillingClient.newBuilder(activity)
.setListener(this)
.enablePendingPurchases()
.build()
billingClient.startConnection(object : BillingClientStateListener {
override fun onBillingSetupFinished(billingResult: BillingResult) {
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "Billing client setup finished successfully")
callback(true)
} else {
Log.e(TAG, "Billing setup failed: ${billingResult.debugMessage}")
callback(false)
}
}
override fun onBillingServiceDisconnected() {
Log.w(TAG, "Billing service disconnected")
// Try to reconnect
initialize { }
}
})
}
fun fetchProducts(productIds: List<String>, callback: (List<ProductData>) -> Unit) {
if (!::billingClient.isInitialized || !billingClient.isReady) {
Log.e(TAG, "Billing client not ready")
callback(emptyList())
return
}
scope.launch {
val products = mutableListOf<ProductData>()
// Check for subscription products
val subsIds = productIds.filter {
it.contains("monthly") || it.contains("yearly") || it.contains("subscription")
}
if (subsIds.isNotEmpty()) {
fetchProductsOfType(subsIds, BillingClient.ProductType.SUBS) { subProducts ->
products.addAll(subProducts)
// Then fetch in-app products
val inAppIds = productIds - subsIds.toSet()
if (inAppIds.isNotEmpty()) {
fetchProductsOfType(inAppIds, BillingClient.ProductType.INAPP) { inAppProducts ->
products.addAll(inAppProducts)
callback(products)
}
} else {
callback(products)
}
}
} else {
// Only in-app products
fetchProductsOfType(productIds, BillingClient.ProductType.INAPP) { inAppProducts ->
products.addAll(inAppProducts)
callback(products)
}
}
}
}
private fun fetchProductsOfType(
productIds: List<String>,
productType: String,
callback: (List<ProductData>) -> Unit
) {
val productList = productIds.map { productId ->
QueryProductDetailsParams.Product.newBuilder()
.setProductId(productId)
.setProductType(productType)
.build()
}
val params = QueryProductDetailsParams.newBuilder()
.setProductList(productList)
.build()
billingClient.queryProductDetailsAsync(params) { billingResult, productDetailsList ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
val products = productDetailsList.map { productDetails ->
// Cache for purchase later
productsCache[productDetails.productId] = productDetails
when (productType) {
BillingClient.ProductType.SUBS -> {
val offer = productDetails.subscriptionOfferDetails?.firstOrNull()
val pricingPhase = offer?.pricingPhases?.pricingPhaseList?.firstOrNull()
pricingPhase?.let {
ProductData(
id = productDetails.productId,
title = productDetails.title,
description = productDetails.description,
price = it.formattedPrice,
priceCurrencyCode = it.priceCurrencyCode,
priceAmountMicros = it.priceAmountMicros,
productType = "subscription"
)
}
}
BillingClient.ProductType.INAPP -> {
val oneTimeOffer = productDetails.oneTimePurchaseOfferDetails
oneTimeOffer?.let {
ProductData(
id = productDetails.productId,
title = productDetails.title,
description = productDetails.description,
price = it.formattedPrice,
priceCurrencyCode = it.priceCurrencyCode,
priceAmountMicros = it.priceAmountMicros,
productType = "consumable"
)
}
}
else -> null
}
}.filterNotNull()
callback(products)
} else {
Log.e(TAG, "Failed to fetch products: ${billingResult.debugMessage}")
callback(emptyList())
}
}
}
fun purchaseProduct(productId: String, callback: (PurchaseData?) -> Unit) {
val productDetails = productsCache[productId]
if (productDetails == null) {
Log.e(TAG, "Product not found in cache: $productId")
callback(null)
return
}
purchaseCallback = callback
val productDetailsParamsList = listOf(
BillingFlowParams.ProductDetailsParams.newBuilder()
.setProductDetails(productDetails)
.apply {
// For subscriptions, use the first offer
productDetails.subscriptionOfferDetails?.firstOrNull()?.let { offer ->
setOfferToken(offer.offerToken)
}
}
.build()
)
val billingFlowParams = BillingFlowParams.newBuilder()
.setProductDetailsParamsList(productDetailsParamsList)
.build()
val billingResult = billingClient.launchBillingFlow(activity, billingFlowParams)
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
Log.e(TAG, "Failed to launch billing flow: ${billingResult.debugMessage}")
callback(null)
purchaseCallback = null
}
}
fun restorePurchases(callback: (List<PurchaseData>) -> Unit) {
if (!::billingClient.isInitialized || !billingClient.isReady) {
Log.e(TAG, "Billing client not ready")
callback(emptyList())
return
}
scope.launch {
val allPurchases = mutableListOf<PurchaseData>()
// Query in-app purchases
val inappParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.INAPP)
.build()
billingClient.queryPurchasesAsync(inappParams) { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
allPurchases.addAll(purchases.map { purchase ->
convertToPurchaseData(purchase, "restored")
})
}
// Query subscription purchases
val subsParams = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(subsParams) { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
allPurchases.addAll(purchases.map { purchase ->
convertToPurchaseData(purchase, "restored")
})
}
callback(allPurchases)
}
}
}
}
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
when (billingResult.responseCode) {
BillingClient.BillingResponseCode.OK -> {
purchases?.forEach { purchase ->
handlePurchase(purchase)
}
}
BillingClient.BillingResponseCode.USER_CANCELED -> {
Log.d(TAG, "Purchase cancelled by user")
purchaseCallback?.invoke(null)
purchaseCallback = null
}
else -> {
Log.e(TAG, "Purchase failed: ${billingResult.debugMessage}")
purchaseCallback?.invoke(null)
purchaseCallback = null
}
}
}
private fun handlePurchase(purchase: Purchase) {
// Acknowledge the purchase
if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) {
if (!purchase.isAcknowledged) {
val acknowledgePurchaseParams = AcknowledgePurchaseParams.newBuilder()
.setPurchaseToken(purchase.purchaseToken)
.build()
billingClient.acknowledgePurchase(acknowledgePurchaseParams) { billingResult ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
Log.d(TAG, "Purchase acknowledged")
}
}
}
val purchaseData = convertToPurchaseData(purchase, "purchased")
purchaseCallback?.invoke(purchaseData)
purchaseCallback = null
}
}
private fun convertToPurchaseData(purchase: Purchase, state: String): PurchaseData {
val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US)
dateFormat.timeZone = TimeZone.getTimeZone("UTC")
return PurchaseData(
platform = "android",
productId = purchase.products.firstOrNull() ?: "",
orderId = purchase.orderId ?: purchase.purchaseToken,
purchaseToken = purchase.purchaseToken,
purchaseDate = dateFormat.format(Date(purchase.purchaseTime)),
purchaseState = state,
)
}
}

View file

@ -31,6 +31,7 @@ import app.tauri.annotation.InvokeArg
import app.tauri.annotation.Permission
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.JSObject
import app.tauri.plugin.JSArray
import app.tauri.plugin.Plugin
import app.tauri.plugin.Invoke
import org.json.JSONArray
@ -38,42 +39,71 @@ import java.io.*
@InvokeArg
class AuthRequestArgs {
var authUrl: String? = null
var authUrl: String? = null
}
@InvokeArg
class CopyURIRequestArgs {
var uri: String? = null
var dst: String? = null
var uri: String? = null
var dst: String? = null
}
@InvokeArg
class InstallPackageRequestArgs {
var path: String? = null
var path: String? = null
}
@InvokeArg
class SetSystemUIVisibilityRequestArgs {
var visible: Boolean? = false
var darkMode: Boolean? = false
var visible: Boolean? = false
var darkMode: Boolean? = false
}
@InvokeArg
class InterceptKeysRequestArgs {
var volumeKeys: Boolean? = null
var backKey: Boolean? = null
var volumeKeys: Boolean? = null
var backKey: Boolean? = null
}
@InvokeArg
class LockScreenOrientationRequestArgs {
var orientation: String? = null
var orientation: String? = null
}
@InvokeArg
class SetScreenBrightnessRequestArgs {
var brightness: Double? = null // 0.0 to 1.0
var brightness: Double? = null // 0.0 to 1.0
}
@InvokeArg
class FetchProductsRequestArgs {
val productIds: List<String>? = null
}
@InvokeArg
class PurchaseProductRequestArgs {
val productId: String? = null
}
data class ProductData(
val id: String,
val title: String,
val description: String,
val price: String,
val priceCurrencyCode: String?,
val priceAmountMicros: Long,
val productType: String
)
data class PurchaseData(
val productId: String,
val orderId: String,
val purchaseToken: String,
val purchaseDate: String,
val purchaseState: String,
val platform: String = "android"
)
interface KeyDownInterceptor {
fun interceptVolumeKeys(enabled: Boolean)
fun interceptBackKey(enabled: Boolean)
@ -88,6 +118,9 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
private val implementation = NativeBridge()
private var redirectScheme = "readest"
private var redirectHost = "auth-callback"
private val billingManager by lazy {
BillingManager(activity)
}
companion object {
var pendingInvoke: Invoke? = null
@ -458,6 +491,103 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
invoke.resolve(ret)
}
@Command
fun iap_initialize(invoke: Invoke) {
billingManager.initialize { success ->
val result = JSObject()
result.put("success", success)
invoke.resolve(result)
}
}
@Command
fun iap_fetch_products(invoke: Invoke) {
try {
val args = invoke.parseArgs(FetchProductsRequestArgs::class.java)
val productIds = args.productIds ?: emptyList()
if (productIds.isEmpty()) {
invoke.reject("Product IDs list is empty")
return
}
billingManager.fetchProducts(productIds) { products ->
val result = JSObject()
val productsArray = JSArray()
for (product in products) {
val productObject = JSObject().apply {
put("id", product.id)
put("title", product.title)
put("description", product.description)
put("price", product.price)
put("priceCurrencyCode", product.priceCurrencyCode)
put("priceAmountMicros", product.priceAmountMicros)
put("productType", product.productType)
}
productsArray.put(productObject)
}
result.put("products", productsArray)
invoke.resolve(result)
}
} catch (e: Exception) {
invoke.reject("Failed to parse fetch products arguments: ${e.message}")
}
}
@Command
fun iap_purchase_product(invoke: Invoke) {
try {
val args = invoke.parseArgs(PurchaseProductRequestArgs::class.java)
val productId = args.productId ?: ""
if (productId.isEmpty()) {
invoke.reject("Product ID is empty")
return
}
billingManager.purchaseProduct(productId) { purchase ->
if (purchase != null) {
val result = JSObject()
val purchaseData = JSObject().apply {
put("platform", purchase.platform)
put("packageName", activity.packageName)
put("productId", purchase.productId)
put("orderId", purchase.orderId)
put("purchaseToken", purchase.purchaseToken)
put("purchaseDate", purchase.purchaseDate)
put("purchaseState", purchase.purchaseState)
}
result.put("purchase", purchaseData)
invoke.resolve(result)
} else {
invoke.reject("Purchase failed or was cancelled")
}
}
} catch (e: Exception) {
invoke.reject("Failed to parse purchase arguments: ${e.message}")
}
}
@Command
fun iap_restore_purchases(invoke: Invoke) {
billingManager.restorePurchases { purchases ->
val result = JSObject()
val purchasesArray = JSArray()
for (purchase in purchases) {
val purchaseObject = JSObject().apply {
put("platform", purchase.platform)
put("packageName", activity.packageName)
put("productId", purchase.productId)
put("orderId", purchase.orderId)
put("purchaseToken", purchase.purchaseToken)
put("purchaseDate", purchase.purchaseDate)
put("purchaseState", purchase.purchaseState)
}
purchasesArray.put(purchaseObject)
}
result.put("purchases", purchasesArray)
invoke.resolve(result)
}
}
@Command
fun request_manage_storage_permission(invoke: Invoke) {
val ret = JSObject()