mirror of
https://github.com/readest/readest.git
synced 2026-04-29 12:00:49 +00:00
feat: support Android IAP (#2286)
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
Some checks are pending
Deploy to vercel on merge / build_and_deploy (push) Waiting to run
This commit is contained in:
parent
28ef414c3d
commit
8737535b90
21 changed files with 1439 additions and 93 deletions
|
|
@ -33,6 +33,7 @@ android {
|
|||
}
|
||||
|
||||
dependencies {
|
||||
implementation("com.android.billingclient:billing-ktx:7.1.1")
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.appcompat:appcompat:1.6.1")
|
||||
implementation("androidx.browser:browser:1.8.0")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue