feat: support Android IAP (#2286)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@readest/readest-app",
|
||||
"version": "0.9.88",
|
||||
"version": "0.9.90",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "dotenv -e .env.tauri -- next dev --turbopack",
|
||||
@@ -74,6 +74,8 @@
|
||||
"dayjs": "^1.11.13",
|
||||
"foliate-js": "workspace:*",
|
||||
"franc-min": "^6.2.0",
|
||||
"google-auth-library": "^10.4.1",
|
||||
"googleapis": "^164.1.0",
|
||||
"highlight.js": "^11.11.1",
|
||||
"i18next": "^24.2.0",
|
||||
"i18next-browser-languagedetector": "^8.0.2",
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||
<uses-permission android:name="com.android.vending.BILLING" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>
|
||||
|
||||
@@ -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")
|
||||
|
||||
+283
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+140
-10
@@ -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()
|
||||
|
||||
@@ -102,12 +102,15 @@ pub struct Product {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Purchase {
|
||||
pub product_id: String,
|
||||
pub transaction_id: String,
|
||||
pub purchase_date: String,
|
||||
pub original_transaction_id: String,
|
||||
pub purchase_state: String, // "purchased", "pending", "cancelled"
|
||||
pub platform: String, // "ios" or "android"
|
||||
pub package_name: Option<String>,
|
||||
pub product_id: String,
|
||||
pub transaction_id: Option<String>,
|
||||
pub original_transaction_id: Option<String>,
|
||||
pub order_id: Option<String>,
|
||||
pub purchase_token: Option<String>,
|
||||
pub purchase_date: String,
|
||||
pub purchase_state: String, // "purchased", "pending", "cancelled", "restored"
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { POST } from '@/app/api/apple/iap-verify/route';
|
||||
import { POST as applePost } from '@/app/api/apple/iap-verify/route';
|
||||
import { POST as googlePost } from '@/app/api/google/iap-verify/route';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { setupSupabaseMocks } from '../helpers/supabase-mock';
|
||||
|
||||
@@ -16,7 +17,7 @@ vi.mock('@/utils/supabase', () => ({
|
||||
}));
|
||||
|
||||
describe.skipIf(SKIP_IAP_API_TESTS)('/api/apple/iap-verify', () => {
|
||||
it('should verify a valid transaction', async () => {
|
||||
it('should verify a valid Apple IAP transaction', async () => {
|
||||
setupSupabaseMocks();
|
||||
const request = new NextRequest('http://localhost:3000/api/apple/iap-verify', {
|
||||
method: 'POST',
|
||||
@@ -30,7 +31,34 @@ describe.skipIf(SKIP_IAP_API_TESTS)('/api/apple/iap-verify', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
const response = await applePost(request);
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.purchase).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(SKIP_IAP_API_TESTS)('/api/google/iap-verify', () => {
|
||||
it('should verify a valid Google IAP purchase', async () => {
|
||||
setupSupabaseMocks();
|
||||
const request = new NextRequest('http://localhost:3000/api/google/iap-verify', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: 'Bearer test-token',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
packageName: 'com.bilingify.readest',
|
||||
productId: 'com.bilingify.readest.monthly.pro',
|
||||
orderId: 'GPA.3388-4630-1043-97604',
|
||||
purchaseToken:
|
||||
'bhedilellajejnodjfcanjai.AO-J1Ow94pzeLM6e8pCJLT_tV-RnffT3HKMTcstovMNVlUTOwhx38SU5Seq3EO5qiQ0Le_VQU1ShN7nxaFQILY3UPX2nhdAKLBbekC_MxBnRf-Bpgegh_NA',
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await googlePost(request);
|
||||
const data = await response.json();
|
||||
console.log('Response:', data);
|
||||
|
||||
|
||||
@@ -9,10 +9,7 @@ export const setupSupabaseMocks = async (
|
||||
update: null,
|
||||
insert: null,
|
||||
adminUpsert: null,
|
||||
adminSelect: {
|
||||
data: [],
|
||||
error: null,
|
||||
},
|
||||
adminSelect: null,
|
||||
adminSelectMany: null,
|
||||
adminUpdate: null,
|
||||
adminInsert: null,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { AppleIAPVerifier, createAppleIAPVerifier } from '@/libs/iap/apple/verif
|
||||
const SKIP_IAP_API_TESTS = !process.env['ENABLE_IAP_API_TESTS'];
|
||||
const REAL_TEST_DATA = {
|
||||
validSubscription: {
|
||||
transactionId: '2000000969168810',
|
||||
transactionId: '2000000976418990',
|
||||
originalTransactionId: '2000000968585424',
|
||||
productId: 'com.bilingify.readest.monthly.plus',
|
||||
},
|
||||
@@ -43,7 +43,7 @@ describe.skipIf(SKIP_IAP_API_TESTS)('Apple IAP Integration Tests', () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.verified).toBe(true);
|
||||
expect(result.status).toBe('active');
|
||||
expect(result.status).toBe('expired');
|
||||
expect(result.transactionId).toBe(transactionId);
|
||||
expect(result.originalTransactionId).toBe(originalTransactionId);
|
||||
expect(result.bundleId).toBe(process.env['APPLE_IAP_BUNDLE_ID']);
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import { z } from 'zod';
|
||||
import { NextResponse } from 'next/server';
|
||||
import {
|
||||
getGoogleIAPVerifier,
|
||||
ProductPurchase,
|
||||
SubscriptionPurchase,
|
||||
} from '@/libs/iap/google/verifier';
|
||||
import { createSupabaseAdminClient } from '@/utils/supabase';
|
||||
import { validateUserAndToken } from '@/utils/access';
|
||||
import { IAPError } from '@/types/error';
|
||||
|
||||
const iapVerificationSchema = z.object({
|
||||
packageName: z.string().min(1, 'Package name is required'),
|
||||
productId: z.string().min(1, 'Product ID is required'),
|
||||
orderId: z.string().min(1, 'Order ID is required'),
|
||||
purchaseToken: z.string().min(1, 'Purchase token is required'),
|
||||
});
|
||||
|
||||
const PRODUCT_MAP: Record<string, string> = {
|
||||
'com.bilingify.readest.monthly.plus': 'Plus',
|
||||
'com.bilingify.readest.yearly.plus': 'Plus',
|
||||
'com.bilingify.readest.monthly.pro': 'Pro',
|
||||
'com.bilingify.readest.yearly.pro': 'Pro',
|
||||
};
|
||||
|
||||
const getProductName = (productId: string) => {
|
||||
return PRODUCT_MAP[productId] || productId;
|
||||
};
|
||||
|
||||
const getProductPlan = (productId: string) => {
|
||||
if (productId.includes('plus')) {
|
||||
return 'plus';
|
||||
} else if (productId.includes('pro')) {
|
||||
return 'pro';
|
||||
}
|
||||
return 'free';
|
||||
};
|
||||
|
||||
interface Purchase {
|
||||
status: string;
|
||||
customerEmail: string;
|
||||
subscriptionId: string;
|
||||
planName: string;
|
||||
productId: string;
|
||||
platform: string;
|
||||
purchaseToken: string;
|
||||
orderId: string;
|
||||
purchaseDate?: string;
|
||||
expiresDate?: string | null;
|
||||
quantity: number;
|
||||
environment: string;
|
||||
packageName: string;
|
||||
purchaseState?: number | null;
|
||||
acknowledgementState?: number | null;
|
||||
autoRenewing?: boolean | null;
|
||||
priceAmountMicros?: string | null;
|
||||
priceCurrencyCode?: string | null;
|
||||
countryCode?: string | null;
|
||||
developerPayload?: string | null;
|
||||
linkedPurchaseToken?: string | null;
|
||||
obfuscatedExternalAccountId?: string | null;
|
||||
obfuscatedExternalProfileId?: string | null;
|
||||
cancelReason?: number | null;
|
||||
userCancellationTimeMillis?: string | null;
|
||||
}
|
||||
|
||||
async function updateUserSubscription(userId: string, purchase: Purchase) {
|
||||
try {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
const { data, error } = await supabase.from('google_iap_subscriptions').upsert(
|
||||
{
|
||||
user_id: userId,
|
||||
platform: purchase.platform,
|
||||
product_id: purchase.productId,
|
||||
purchase_token: purchase.purchaseToken,
|
||||
order_id: purchase.orderId,
|
||||
status: purchase.status === 'active' ? 'active' : 'expired',
|
||||
purchase_date: purchase.purchaseDate,
|
||||
expires_date: purchase.expiresDate,
|
||||
environment: purchase.environment,
|
||||
package_name: purchase.packageName,
|
||||
quantity: purchase.quantity || 1,
|
||||
auto_renew_status: purchase.autoRenewing || false,
|
||||
purchase_state: purchase.purchaseState,
|
||||
acknowledgement_state: purchase.acknowledgementState,
|
||||
price_amount_micros: purchase.priceAmountMicros,
|
||||
price_currency_code: purchase.priceCurrencyCode,
|
||||
country_code: purchase.countryCode,
|
||||
developer_payload: purchase.developerPayload,
|
||||
linked_purchase_token: purchase.linkedPurchaseToken,
|
||||
obfuscated_external_account_id: purchase.obfuscatedExternalAccountId,
|
||||
obfuscated_external_profile_id: purchase.obfuscatedExternalProfileId,
|
||||
cancel_reason: purchase.cancelReason,
|
||||
user_cancellation_time_millis: purchase.userCancellationTimeMillis,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
},
|
||||
{
|
||||
onConflict: 'user_id,order_id',
|
||||
},
|
||||
);
|
||||
|
||||
if (error) {
|
||||
console.error('Database update error:', error);
|
||||
throw new Error(`Database update failed: ${error.message}`);
|
||||
}
|
||||
|
||||
const plan = getProductPlan(purchase.productId);
|
||||
await supabase
|
||||
.from('plans')
|
||||
.update({
|
||||
plan: ['active', 'trialing'].includes(purchase.status) ? plan : 'free',
|
||||
status: purchase.status,
|
||||
})
|
||||
.eq('id', userId);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Failed to update user subscription:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const body = await request.json();
|
||||
let validatedInput;
|
||||
try {
|
||||
validatedInput = iapVerificationSchema.parse(body);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Invalid input data',
|
||||
purchase: null,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
const { purchaseToken, productId, packageName, orderId } = validatedInput!;
|
||||
|
||||
const { user, token } = await validateUserAndToken(request.headers.get('authorization'));
|
||||
if (!user || !token) {
|
||||
return NextResponse.json({ error: IAPError.NOT_AUTHENTICATED }, { status: 403 });
|
||||
}
|
||||
|
||||
try {
|
||||
const supabase = createSupabaseAdminClient();
|
||||
|
||||
// Check if this purchase already exists
|
||||
if (orderId) {
|
||||
const { data: existingSubscription } = await supabase
|
||||
.from('google_iap_subscriptions')
|
||||
.select('*')
|
||||
.eq('order_id', orderId)
|
||||
.single();
|
||||
|
||||
console.log('Existing subscription:', existingSubscription);
|
||||
|
||||
// Should not restore purchase for another account
|
||||
if (existingSubscription && existingSubscription.user_id !== user.id) {
|
||||
return NextResponse.json(
|
||||
{ error: IAPError.TRANSACTION_BELONGS_TO_ANOTHER_USER },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
existingSubscription &&
|
||||
existingSubscription.purchase_token === purchaseToken &&
|
||||
existingSubscription.status === 'active'
|
||||
) {
|
||||
console.log('Transaction already verified and active');
|
||||
|
||||
const purchase = {
|
||||
status: existingSubscription.status,
|
||||
customerEmail: user.email,
|
||||
subscriptionId: existingSubscription.order_id,
|
||||
planName: getProductName(existingSubscription.product_id),
|
||||
productId: existingSubscription.product_id,
|
||||
platform: existingSubscription.platform,
|
||||
purchaseToken: existingSubscription.purchase_token,
|
||||
orderId: existingSubscription.order_id,
|
||||
purchaseDate: existingSubscription.purchase_date,
|
||||
expiresDate: existingSubscription.expires_date,
|
||||
quantity: existingSubscription.quantity,
|
||||
environment: existingSubscription.environment,
|
||||
packageName: existingSubscription.package_name,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
purchase,
|
||||
error: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const googleIAPVerifier = getGoogleIAPVerifier();
|
||||
const verificationResult = await googleIAPVerifier.verifyPurchase({
|
||||
purchaseToken,
|
||||
productId,
|
||||
packageName,
|
||||
});
|
||||
|
||||
if (!verificationResult.success) {
|
||||
console.error('Google verification failed:', verificationResult.error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: verificationResult.error || IAPError.UNKNOWN_ERROR,
|
||||
purchase: null,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const purchaseData = verificationResult.purchaseData!;
|
||||
const isSubscription = verificationResult.purchaseType === 'subscription';
|
||||
console.log('Google verification successful:', {
|
||||
orderId: purchaseData.orderId,
|
||||
productId: productId,
|
||||
purchaseState: purchaseData.purchaseState,
|
||||
});
|
||||
|
||||
// Check environment (test purchases have specific patterns in orderId)
|
||||
const isTestPurchase = purchaseData.purchaseType === 0; // 0 = Test, 1 = Promo, undefined = Real
|
||||
if (isTestPurchase && process.env.NODE_ENV === 'production') {
|
||||
console.warn('Test purchase in production environment');
|
||||
}
|
||||
|
||||
let purchase: Purchase;
|
||||
|
||||
if (isSubscription) {
|
||||
const subData = purchaseData as SubscriptionPurchase;
|
||||
purchase = {
|
||||
status: verificationResult.status!,
|
||||
customerEmail: user.email!,
|
||||
subscriptionId: subData.orderId || purchaseToken,
|
||||
planName: getProductName(productId),
|
||||
productId: productId,
|
||||
platform: 'android',
|
||||
purchaseToken: purchaseToken,
|
||||
orderId: subData.orderId || '',
|
||||
purchaseDate: verificationResult.purchaseDate?.toISOString(),
|
||||
expiresDate: verificationResult.expiresDate?.toISOString() || null,
|
||||
quantity: subData.quantity || 1,
|
||||
environment: isTestPurchase ? 'sandbox' : 'production',
|
||||
packageName: packageName,
|
||||
purchaseState: subData.purchaseState,
|
||||
acknowledgementState: subData.acknowledgementState,
|
||||
autoRenewing: subData.autoRenewing,
|
||||
priceAmountMicros: subData.priceAmountMicros,
|
||||
priceCurrencyCode: subData.priceCurrencyCode,
|
||||
countryCode: subData.countryCode,
|
||||
developerPayload: subData.developerPayload,
|
||||
linkedPurchaseToken: subData.linkedPurchaseToken,
|
||||
obfuscatedExternalAccountId: subData.obfuscatedExternalAccountId,
|
||||
obfuscatedExternalProfileId: subData.obfuscatedExternalProfileId,
|
||||
cancelReason: subData.cancelReason,
|
||||
userCancellationTimeMillis: subData.userCancellationTimeMillis,
|
||||
};
|
||||
} else {
|
||||
const prodData = purchaseData as ProductPurchase;
|
||||
purchase = {
|
||||
status: verificationResult.status!,
|
||||
customerEmail: user.email!,
|
||||
subscriptionId: prodData.orderId || purchaseToken,
|
||||
planName: getProductName(productId),
|
||||
productId: productId,
|
||||
platform: 'android',
|
||||
purchaseToken: purchaseToken,
|
||||
orderId: prodData.orderId || '',
|
||||
purchaseDate: verificationResult.purchaseDate?.toISOString(),
|
||||
expiresDate: null, // One-time purchases don't expire
|
||||
quantity: prodData.quantity || 1,
|
||||
environment: isTestPurchase ? 'sandbox' : 'production',
|
||||
packageName: packageName,
|
||||
purchaseState: prodData.purchaseState,
|
||||
acknowledgementState: prodData.acknowledgementState,
|
||||
autoRenewing: false, // Not applicable for one-time purchases
|
||||
priceAmountMicros: undefined,
|
||||
priceCurrencyCode: prodData.regionCode,
|
||||
countryCode: prodData.regionCode,
|
||||
developerPayload: prodData.developerPayload,
|
||||
linkedPurchaseToken: undefined,
|
||||
obfuscatedExternalAccountId: prodData.obfuscatedExternalAccountId,
|
||||
obfuscatedExternalProfileId: prodData.obfuscatedExternalProfileId,
|
||||
cancelReason: null,
|
||||
userCancellationTimeMillis: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Acknowledge the purchase if needed
|
||||
if (purchaseData.acknowledgementState === 0) {
|
||||
try {
|
||||
await googleIAPVerifier.acknowledgePurchase({
|
||||
purchaseToken,
|
||||
productId,
|
||||
packageName,
|
||||
});
|
||||
purchase.acknowledgementState = 1;
|
||||
} catch (ackError) {
|
||||
console.error('Failed to acknowledge purchase:', ackError);
|
||||
// Continue even if acknowledgement fails
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await updateUserSubscription(user.id, purchase);
|
||||
} catch (dbError) {
|
||||
console.error('Database update failed:', dbError);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: IAPError.TRANSACTION_SERVICE_UNAVAILABLE,
|
||||
purchase: null,
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
purchase,
|
||||
error: null,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('IAP verification error:', error);
|
||||
return NextResponse.json(
|
||||
{ error: error instanceof Error ? error.message : IAPError.UNKNOWN_ERROR },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,7 @@ const AccountActions: React.FC<AccountActionsProps> = ({
|
||||
}}
|
||||
/>
|
||||
<div className='flex flex-col gap-4 md:flex-row'>
|
||||
{appService?.isIOSApp ? (
|
||||
{appService?.hasIAP ? (
|
||||
<button
|
||||
onClick={onRestorePurchase}
|
||||
className='w-full rounded-lg bg-blue-100 px-6 py-3 font-medium text-blue-600 transition-colors hover:bg-blue-200 md:w-auto'
|
||||
|
||||
@@ -144,7 +144,7 @@ const PlansComparison: React.FC<PlansComparisonProps> = ({
|
||||
<PlanCard
|
||||
key={plan.plan}
|
||||
plan={plan}
|
||||
comingSoon={['playstore'].includes(appService?.distChannel || '')}
|
||||
comingSoon={false}
|
||||
isUserPlan={plan.plan === userPlan}
|
||||
upgradable={index > userPlanIndex}
|
||||
index={index}
|
||||
|
||||
@@ -20,7 +20,7 @@ import { getStripe } from '@/libs/stripe/client';
|
||||
import { getAPIBaseUrl, isTauriAppPlatform, isWebAppPlatform } from '@/services/environment';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import { getAccessToken } from '@/utils/access';
|
||||
import { IAPService, IAPProduct } from '@/utils/iap';
|
||||
import { IAPService, IAPProduct, IAPPurchase } from '@/utils/iap';
|
||||
import { getPlanDetails } from './utils/plan';
|
||||
import { Toast } from '@/components/Toast';
|
||||
import LegalLinks from '@/components/LegalLinks';
|
||||
@@ -184,6 +184,19 @@ const ProfilePage = () => {
|
||||
[router],
|
||||
);
|
||||
|
||||
const getPuchaseVerifyParams = (purchase: IAPPurchase) => {
|
||||
return new URLSearchParams({
|
||||
payment: 'iap',
|
||||
platform: purchase.platform,
|
||||
product_id: purchase.productId,
|
||||
transaction_id: purchase.transactionId || '',
|
||||
original_transaction_id: purchase.originalTransactionId || '',
|
||||
package_name: purchase.packageName || '',
|
||||
order_id: purchase.orderId || '',
|
||||
purchase_token: purchase.purchaseToken || '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleIAPSubscribe = async (productId?: string) => {
|
||||
if (!productId) return;
|
||||
|
||||
@@ -192,12 +205,7 @@ const ProfilePage = () => {
|
||||
try {
|
||||
const purchase = await iapService.purchaseProduct(productId);
|
||||
if (purchase) {
|
||||
const params = new URLSearchParams({
|
||||
payment: 'iap',
|
||||
platform: purchase.platform,
|
||||
transaction_id: purchase.transactionId,
|
||||
original_transaction_id: purchase.originalTransactionId,
|
||||
});
|
||||
const params = getPuchaseVerifyParams(purchase);
|
||||
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -213,15 +221,10 @@ const ProfilePage = () => {
|
||||
const purchases = await iapService.restorePurchases();
|
||||
if (purchases.length > 0) {
|
||||
purchases.sort(
|
||||
(a, b) => new Date(a.purchaseDate).getTime() - new Date(b.purchaseDate).getTime(),
|
||||
(a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime(),
|
||||
);
|
||||
const purchase = purchases[0]!;
|
||||
const params = new URLSearchParams({
|
||||
payment: 'iap',
|
||||
platform: purchase.platform,
|
||||
transaction_id: purchase.transactionId,
|
||||
original_transaction_id: purchase.originalTransactionId,
|
||||
});
|
||||
const params = getPuchaseVerifyParams(purchase);
|
||||
router.push(`${SUBSCRIPTION_SUCCESS_PATH}?${params.toString()}`);
|
||||
} else {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
@@ -272,7 +275,7 @@ const ProfilePage = () => {
|
||||
useEffect(() => {
|
||||
if (!appService) return;
|
||||
|
||||
if (appService?.isIOSApp) {
|
||||
if (appService?.hasIAP) {
|
||||
const iapService = new IAPService();
|
||||
iapService
|
||||
.initialize()
|
||||
@@ -384,7 +387,7 @@ const ProfilePage = () => {
|
||||
<PlansComparison
|
||||
availablePlans={availablePlans}
|
||||
userPlan={userPlan}
|
||||
onSubscribe={appService.isIOSApp ? handleIAPSubscribe : handleStripeSubscribe}
|
||||
onSubscribe={appService.hasIAP ? handleIAPSubscribe : handleStripeSubscribe}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import Spinner from '@/components/Spinner';
|
||||
|
||||
const STRIPE_CHECK_URL = `${getAPIBaseUrl()}/stripe/check`;
|
||||
const APPLE_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/apple/iap-verify`;
|
||||
const ANDROID_IAP_VERIFY_URL = `${getNodeAPIBaseUrl()}/google/iap-verify`;
|
||||
|
||||
interface SessionStatus {
|
||||
status: 'loading' | 'complete' | 'failed' | 'processing';
|
||||
@@ -31,9 +32,17 @@ const SuccessPageWithSearchParams = () => {
|
||||
const payment = searchParams?.get('payment');
|
||||
const platform = searchParams?.get('platform');
|
||||
const sessionId = searchParams?.get('session_id');
|
||||
|
||||
// iOS parameters
|
||||
const transactionId = searchParams?.get('transaction_id');
|
||||
const originalTransactionId = searchParams?.get('original_transaction_id');
|
||||
|
||||
// Android parameters
|
||||
const packageName = searchParams?.get('package_name');
|
||||
const productId = searchParams?.get('product_id');
|
||||
const purchaseToken = searchParams?.get('purchase_token');
|
||||
const orderId = searchParams?.get('order_id');
|
||||
|
||||
const [mounted, setMounted] = useState(false);
|
||||
useEffect(() => setMounted(true), []);
|
||||
|
||||
@@ -81,8 +90,7 @@ const SuccessPageWithSearchParams = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const updateIAPSessionStatus = async (
|
||||
platform: string,
|
||||
const updateIOSIAPSessionStatus = async (
|
||||
transactionId: string,
|
||||
originalTransactionId: string,
|
||||
) => {
|
||||
@@ -90,55 +98,124 @@ const SuccessPageWithSearchParams = () => {
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
return;
|
||||
}
|
||||
if (platform === 'ios') {
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const response = await fetch(APPLE_IAP_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transactionId,
|
||||
originalTransactionId,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const response = await fetch(APPLE_IAP_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
transactionId,
|
||||
originalTransactionId,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const { purchase, error } = await response.json();
|
||||
|
||||
if (error) {
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
console.error('IAP verification error:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionStatus({
|
||||
status: purchase.status === 'active' ? 'complete' : 'failed',
|
||||
customerEmail: purchase.customerEmail || '',
|
||||
subscriptionId: purchase.subscriptionId,
|
||||
planName: purchase.planName,
|
||||
});
|
||||
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error('Failed to verify IAP transaction:', error);
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const { purchase, error } = await response.json();
|
||||
|
||||
if (error) {
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
console.error('IAP verification error:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionStatus({
|
||||
status: purchase.status === 'active' ? 'complete' : 'failed',
|
||||
customerEmail: purchase.customerEmail || '',
|
||||
subscriptionId: purchase.subscriptionId,
|
||||
planName: purchase.planName,
|
||||
});
|
||||
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error('Failed to verify IAP transaction:', error);
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
}
|
||||
};
|
||||
|
||||
const updateAndroidIAPSessionStatus = async (
|
||||
packageName: string,
|
||||
productId: string,
|
||||
orderId: string,
|
||||
purchaseToken: string,
|
||||
) => {
|
||||
if (!purchaseToken || !productId || !packageName) {
|
||||
console.error('Missing required Android IAP parameters');
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getAccessToken();
|
||||
const response = await fetch(ANDROID_IAP_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
purchaseToken,
|
||||
orderId,
|
||||
productId,
|
||||
packageName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
const { purchase, error } = await response.json();
|
||||
|
||||
if (error) {
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
console.error('Android IAP verification error:', error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionStatus({
|
||||
status: purchase.status === 'active' ? 'complete' : 'failed',
|
||||
customerEmail: purchase.customerEmail || '',
|
||||
subscriptionId: purchase.subscriptionId || purchase.orderId,
|
||||
planName: purchase.planName,
|
||||
amount: purchase.priceAmountMicros
|
||||
? Number(purchase.priceAmountMicros) / 1000000
|
||||
: undefined,
|
||||
currency: purchase.priceCurrencyCode,
|
||||
});
|
||||
|
||||
try {
|
||||
await supabase.auth.refreshSession();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.error('Failed to verify Android IAP transaction:', error);
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
}
|
||||
};
|
||||
|
||||
const updateIAPSessionStatus = async () => {
|
||||
if (platform === 'ios' && transactionId && originalTransactionId) {
|
||||
await updateIOSIAPSessionStatus(transactionId, originalTransactionId);
|
||||
} else if (platform === 'android' && orderId && purchaseToken && productId && packageName) {
|
||||
await updateAndroidIAPSessionStatus(packageName, productId, orderId, purchaseToken);
|
||||
} else {
|
||||
console.error('Invalid IAP platform or missing parameters');
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
}
|
||||
};
|
||||
|
||||
const updateSessionStatus = async () => {
|
||||
if (payment === 'stripe' && sessionId) {
|
||||
await updateStripeSessionStatus();
|
||||
} else if (payment === 'iap' && platform && transactionId && originalTransactionId) {
|
||||
await updateIAPSessionStatus(platform, transactionId, originalTransactionId);
|
||||
} else if (payment === 'iap') {
|
||||
await updateIAPSessionStatus();
|
||||
} else {
|
||||
setSessionStatus((prev) => ({ ...prev, status: 'failed' }));
|
||||
}
|
||||
|
||||
@@ -55,7 +55,14 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [envConfig, appService, applyUILanguage, setScreenBrightness, applyBackgroundTexture]);
|
||||
}, [
|
||||
envConfig,
|
||||
appService,
|
||||
applyUILanguage,
|
||||
setScreenBrightness,
|
||||
applyBackgroundTexture,
|
||||
applyEinkMode,
|
||||
]);
|
||||
|
||||
// Make sure appService is available in all children components
|
||||
if (!appService) return;
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
import { google, androidpublisher_v3 } from 'googleapis';
|
||||
import { GoogleAuth, GoogleAuthOptions } from 'google-auth-library';
|
||||
|
||||
interface VerifyPurchaseParams {
|
||||
purchaseToken: string;
|
||||
productId: string;
|
||||
packageName: string;
|
||||
}
|
||||
|
||||
export interface SubscriptionPurchase {
|
||||
kind?: string | null;
|
||||
startTimeMillis?: string | null;
|
||||
expiryTimeMillis?: string | null;
|
||||
autoRenewing?: boolean | null;
|
||||
priceCurrencyCode?: string | null;
|
||||
priceAmountMicros?: string | null;
|
||||
countryCode?: string | null;
|
||||
developerPayload?: string | null;
|
||||
paymentState?: number | null;
|
||||
cancelReason?: number | null;
|
||||
userCancellationTimeMillis?: string | null;
|
||||
orderId?: string | null;
|
||||
linkedPurchaseToken?: string | null;
|
||||
purchaseType?: number | null;
|
||||
acknowledgementState?: number | null;
|
||||
purchaseState?: number | null;
|
||||
quantity?: number | null;
|
||||
obfuscatedExternalAccountId?: string | null;
|
||||
obfuscatedExternalProfileId?: string | null;
|
||||
}
|
||||
|
||||
export interface ProductPurchase {
|
||||
kind?: string | null;
|
||||
purchaseTimeMillis?: string | null;
|
||||
purchaseState?: number | null;
|
||||
consumptionState?: number | null;
|
||||
developerPayload?: string | null;
|
||||
orderId?: string | null;
|
||||
purchaseType?: number | null;
|
||||
acknowledgementState?: number | null;
|
||||
purchaseToken?: string | null;
|
||||
productId?: string | null;
|
||||
quantity?: number | null;
|
||||
obfuscatedExternalAccountId?: string | null;
|
||||
obfuscatedExternalProfileId?: string | null;
|
||||
regionCode?: string | null;
|
||||
}
|
||||
|
||||
export interface VerificationResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
status?: string;
|
||||
purchaseDate?: Date;
|
||||
expiresDate?: Date | null;
|
||||
revocationDate?: Date | null;
|
||||
revocationReason?: number | null;
|
||||
purchaseData?: SubscriptionPurchase | ProductPurchase;
|
||||
purchaseType?: 'subscription' | 'product';
|
||||
}
|
||||
|
||||
export class GoogleIAPVerifier {
|
||||
private auth: GoogleAuth;
|
||||
private androidPublisher: androidpublisher_v3.Androidpublisher;
|
||||
|
||||
constructor() {
|
||||
const authOptions: GoogleAuthOptions = {
|
||||
scopes: ['https://www.googleapis.com/auth/androidpublisher'],
|
||||
};
|
||||
|
||||
if (process.env['GOOGLE_IAP_SERVICE_ACCOUNT_KEY']) {
|
||||
try {
|
||||
authOptions.credentials = JSON.parse(process.env['GOOGLE_IAP_SERVICE_ACCOUNT_KEY']);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse GOOGLE_SERVICE_ACCOUNT_KEY:', e);
|
||||
throw new Error('Invalid Google service account credentials');
|
||||
}
|
||||
} else {
|
||||
console.warn('Using Application Default Credentials');
|
||||
}
|
||||
|
||||
this.auth = new GoogleAuth(authOptions);
|
||||
|
||||
this.androidPublisher = google.androidpublisher({
|
||||
version: 'v3',
|
||||
auth: this.auth,
|
||||
});
|
||||
}
|
||||
|
||||
async verifyPurchase(params: VerifyPurchaseParams): Promise<VerificationResult> {
|
||||
try {
|
||||
// First, try to verify as a subscription
|
||||
const subscriptionResult = await this.verifySubscription(params);
|
||||
if (subscriptionResult.success) {
|
||||
return subscriptionResult;
|
||||
}
|
||||
|
||||
// If subscription verification fails, try as a one-time product purchase
|
||||
const productResult = await this.verifyProduct(params);
|
||||
if (productResult.success) {
|
||||
return productResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unable to verify purchase as subscription or product',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Google Play verification error:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown verification error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async verifySubscription(params: VerifyPurchaseParams): Promise<VerificationResult> {
|
||||
const { purchaseToken, productId, packageName } = params;
|
||||
|
||||
try {
|
||||
const response = await this.androidPublisher.purchases.subscriptions.get({
|
||||
packageName,
|
||||
subscriptionId: productId,
|
||||
token: purchaseToken,
|
||||
});
|
||||
|
||||
const purchase: SubscriptionPurchase = response.data;
|
||||
|
||||
// Check if the subscription is valid
|
||||
const now = Date.now();
|
||||
const expiryTime = purchase.expiryTimeMillis ? parseInt(purchase.expiryTimeMillis) : 0;
|
||||
const startTime = purchase.startTimeMillis ? parseInt(purchase.startTimeMillis) : 0;
|
||||
|
||||
let status = 'expired';
|
||||
if (purchase.paymentState === 1 && expiryTime > now) {
|
||||
status = 'active';
|
||||
} else if (purchase.paymentState === 0) {
|
||||
status = 'pending';
|
||||
} else if (purchase.userCancellationTimeMillis) {
|
||||
status = 'cancelled';
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
purchaseDate: startTime ? new Date(startTime) : undefined,
|
||||
expiresDate: expiryTime ? new Date(expiryTime) : null,
|
||||
revocationDate: purchase.userCancellationTimeMillis
|
||||
? new Date(parseInt(purchase.userCancellationTimeMillis))
|
||||
: null,
|
||||
revocationReason: purchase.cancelReason || null,
|
||||
purchaseData: purchase,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not a subscription purchase',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyProduct(params: VerifyPurchaseParams): Promise<VerificationResult> {
|
||||
const { purchaseToken, productId, packageName } = params;
|
||||
|
||||
try {
|
||||
const response = await this.androidPublisher.purchases.products.get({
|
||||
packageName,
|
||||
productId,
|
||||
token: purchaseToken,
|
||||
});
|
||||
|
||||
const purchase: ProductPurchase = response.data;
|
||||
|
||||
// Check purchase state (0 = purchased, 1 = canceled)
|
||||
const status = purchase.purchaseState === 0 ? 'active' : 'cancelled';
|
||||
const purchaseTime = purchase.purchaseTimeMillis ? parseInt(purchase.purchaseTimeMillis) : 0;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status,
|
||||
purchaseDate: purchaseTime ? new Date(purchaseTime) : undefined,
|
||||
expiresDate: null, // One-time purchases don't expire
|
||||
revocationDate: null,
|
||||
revocationReason: null,
|
||||
purchaseData: purchase,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Purchase not found',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async acknowledgePurchase(params: VerifyPurchaseParams): Promise<void> {
|
||||
const { purchaseToken, productId, packageName } = params;
|
||||
|
||||
try {
|
||||
// Try to acknowledge as subscription first
|
||||
await this.androidPublisher.purchases.subscriptions.acknowledge({
|
||||
packageName,
|
||||
subscriptionId: productId,
|
||||
token: purchaseToken,
|
||||
});
|
||||
} catch {
|
||||
try {
|
||||
await this.androidPublisher.purchases.products.acknowledge({
|
||||
packageName,
|
||||
productId,
|
||||
token: purchaseToken,
|
||||
});
|
||||
} catch (productError) {
|
||||
console.error('Failed to acknowledge product purchase:', productError);
|
||||
throw productError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async cancelSubscription(params: VerifyPurchaseParams): Promise<void> {
|
||||
const { purchaseToken, productId, packageName } = params;
|
||||
|
||||
try {
|
||||
await this.androidPublisher.purchases.subscriptions.cancel({
|
||||
packageName,
|
||||
subscriptionId: productId,
|
||||
token: purchaseToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to cancel subscription:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async refundSubscription(params: VerifyPurchaseParams): Promise<void> {
|
||||
const { purchaseToken, productId, packageName } = params;
|
||||
|
||||
try {
|
||||
await this.androidPublisher.purchases.subscriptions.refund({
|
||||
packageName,
|
||||
subscriptionId: productId,
|
||||
token: purchaseToken,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to refund subscription:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deferSubscription(
|
||||
params: VerifyPurchaseParams & {
|
||||
desiredExpiryTimeMillis: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { purchaseToken, productId, packageName, desiredExpiryTimeMillis } = params;
|
||||
|
||||
try {
|
||||
await this.androidPublisher.purchases.subscriptions.defer({
|
||||
packageName,
|
||||
subscriptionId: productId,
|
||||
token: purchaseToken,
|
||||
requestBody: {
|
||||
deferralInfo: {
|
||||
desiredExpiryTimeMillis,
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to defer subscription:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
let verifierInstance: GoogleIAPVerifier | null = null;
|
||||
|
||||
export function getGoogleIAPVerifier(): GoogleIAPVerifier {
|
||||
if (!verifierInstance) {
|
||||
verifierInstance = new GoogleIAPVerifier();
|
||||
}
|
||||
return verifierInstance;
|
||||
}
|
||||
@@ -88,6 +88,7 @@ export abstract class BaseAppService implements AppService {
|
||||
hasUpdater = false;
|
||||
hasOrientationLock = false;
|
||||
hasScreenBrightness = false;
|
||||
hasIAP = false;
|
||||
canCustomizeRootDir = false;
|
||||
distChannel = 'readest' as DistChannel;
|
||||
|
||||
|
||||
@@ -333,6 +333,8 @@ export const nativeFileSystem: FileSystem = {
|
||||
},
|
||||
};
|
||||
|
||||
const DIST_CHANNEL = (process.env['NEXT_PUBLIC_DIST_CHANNEL'] || 'readest') as DistChannel;
|
||||
|
||||
export class NativeAppService extends BaseAppService {
|
||||
fs = nativeFileSystem;
|
||||
override appPlatform = 'tauri' as AppPlatform;
|
||||
@@ -359,10 +361,11 @@ export class NativeAppService extends BaseAppService {
|
||||
override hasOrientationLock =
|
||||
(OS_TYPE === 'ios' && getOSPlatform() === 'ios') || OS_TYPE === 'android';
|
||||
override hasScreenBrightness = OS_TYPE === 'ios' || OS_TYPE === 'android';
|
||||
override hasIAP = OS_TYPE === 'ios' || (OS_TYPE === 'android' && DIST_CHANNEL === 'playstore');
|
||||
// CustomizeRootDir has a blocker on macOS App Store builds due to Security Scoped Resource restrictions.
|
||||
// See: https://github.com/tauri-apps/tauri/issues/3716
|
||||
override canCustomizeRootDir = process.env['NEXT_PUBLIC_DIST_CHANNEL'] !== 'appstore';
|
||||
override distChannel = (process.env['NEXT_PUBLIC_DIST_CHANNEL'] || 'readest') as DistChannel;
|
||||
override canCustomizeRootDir = DIST_CHANNEL !== 'appstore';
|
||||
override distChannel = DIST_CHANNEL;
|
||||
|
||||
private execDir?: string = undefined;
|
||||
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface AppService {
|
||||
hasUpdater: boolean;
|
||||
hasOrientationLock: boolean;
|
||||
hasScreenBrightness: boolean;
|
||||
hasIAP: boolean;
|
||||
isMobile: boolean;
|
||||
isAppDataSandbox: boolean;
|
||||
isMobileApp: boolean;
|
||||
|
||||
@@ -7,16 +7,34 @@ export interface IAPProduct {
|
||||
price: string;
|
||||
priceCurrencyCode?: string;
|
||||
priceAmountMicros: number;
|
||||
productType?: 'consumable' | 'non_consumable' | 'subscription';
|
||||
}
|
||||
|
||||
export interface IAPPurchase {
|
||||
interface IAPPurchaseBase {
|
||||
productId: string;
|
||||
transactionId: string;
|
||||
originalTransactionId: string;
|
||||
purchaseDate: string;
|
||||
platform: 'ios' | 'android';
|
||||
}
|
||||
|
||||
interface IOSPurchase {
|
||||
platform: 'ios';
|
||||
transactionId?: string;
|
||||
originalTransactionId?: string;
|
||||
packageName?: never;
|
||||
orderId?: never;
|
||||
purchaseToken?: never;
|
||||
}
|
||||
|
||||
interface AndroidPurchase {
|
||||
platform: 'android';
|
||||
packageName?: string;
|
||||
orderId?: string;
|
||||
purchaseToken?: string;
|
||||
transactionId?: never;
|
||||
originalTransactionId?: never;
|
||||
}
|
||||
|
||||
export type IAPPurchase = IAPPurchaseBase & (IOSPurchase | AndroidPurchase);
|
||||
|
||||
interface InitializeRequest {
|
||||
publicKey?: string;
|
||||
}
|
||||
|
||||
Generated
+178
@@ -131,6 +131,12 @@ importers:
|
||||
franc-min:
|
||||
specifier: ^6.2.0
|
||||
version: 6.2.0
|
||||
google-auth-library:
|
||||
specifier: ^10.4.1
|
||||
version: 10.4.1
|
||||
googleapis:
|
||||
specifier: ^164.1.0
|
||||
version: 164.1.0
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
@@ -3586,6 +3592,9 @@ packages:
|
||||
big.js@5.2.2:
|
||||
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
|
||||
|
||||
bignumber.js@9.3.1:
|
||||
resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==}
|
||||
|
||||
binary-extensions@2.3.0:
|
||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3623,6 +3632,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -3854,6 +3866,10 @@ packages:
|
||||
damerau-levenshtein@1.0.8:
|
||||
resolution: {integrity: sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==}
|
||||
|
||||
data-uri-to-buffer@4.0.1:
|
||||
resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
data-urls@5.0.0:
|
||||
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4020,6 +4036,9 @@ packages:
|
||||
eastasianwidth@0.2.0:
|
||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
eciesjs@0.4.14:
|
||||
resolution: {integrity: sha512-eJAgf9pdv214Hn98FlUzclRMYWF7WfoLlkS9nWMTm1qcCwn6Ad4EGD9lr9HXMBfSrZhYQujRE+p0adPRkctC6A==}
|
||||
engines: {bun: '>=1', deno: '>=2', node: '>=16'}
|
||||
@@ -4317,6 +4336,9 @@ packages:
|
||||
exsolve@1.0.7:
|
||||
resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==}
|
||||
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
@@ -4362,6 +4384,10 @@ packages:
|
||||
picomatch:
|
||||
optional: true
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==}
|
||||
engines: {node: ^12.20 || >= 14.13}
|
||||
|
||||
fflate@0.4.8:
|
||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||
|
||||
@@ -4419,6 +4445,10 @@ packages:
|
||||
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
|
||||
engines: {node: '>= 12.20'}
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
forwarded@0.2.0:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
@@ -4467,6 +4497,14 @@ packages:
|
||||
functions-have-names@1.2.3:
|
||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||
|
||||
gaxios@7.1.2:
|
||||
resolution: {integrity: sha512-/Szrn8nr+2TsQT1Gp8iIe/BEytJmbyfrbFh419DfGQSkEgNEhbPi7JRJuughjkTzPWgU9gBQf5AVu3DbHt0OXA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gcp-metadata@8.1.1:
|
||||
resolution: {integrity: sha512-dTCcAe9fRQf06ELwel6lWWFrEbstwjUBYEhr5VRGoC+iPDZQucHppCowaIp8b8v92tU1G4X4H3b/Y6zXZxkMsQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gensync@1.0.0-beta.2:
|
||||
resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@@ -4568,6 +4606,22 @@ packages:
|
||||
globrex@0.1.2:
|
||||
resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==}
|
||||
|
||||
google-auth-library@10.4.1:
|
||||
resolution: {integrity: sha512-VlvZ+QDWng3aPh++0BSQlSJyjn4qgLLTmqylAR3as0dr6YwPkZpHcZAngAFr68TDVCUSQVRTkV73K/D3m7vEIg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
google-logging-utils@1.1.1:
|
||||
resolution: {integrity: sha512-rcX58I7nqpu4mbKztFeOAObbomBbHU2oIb/d3tJfF3dizGSApqtSwYJigGCooHdnMyQBIw8BrWyK96w3YXgr6A==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
googleapis-common@8.0.0:
|
||||
resolution: {integrity: sha512-66if47It7y+Sab3HMkwEXx1kCq9qUC9px8ZXoj1CMrmLmUw81GpbnsNlXnlyZyGbGPGcj+tDD9XsZ23m7GLaJQ==}
|
||||
engines: {node: '>=18.0.0'}
|
||||
|
||||
googleapis@164.1.0:
|
||||
resolution: {integrity: sha512-dIN768H8so9qGucFtjYPBZJ+OCEgDi/xYyvYQHniPL1ZCYvrRDBTmtbjVjKCPG1CuOhG4CKHZDXiFe6QZ2qBeQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gopd@1.1.0:
|
||||
resolution: {integrity: sha512-FQoVQnqcdk4hVM4JN1eromaun4iuS34oStkdlLENLdpULsuQcTyXj8w7ayhuUfPwEYZ1ZOooOTT6fdA9Vmx/RA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -4582,6 +4636,10 @@ packages:
|
||||
graphemer@1.4.0:
|
||||
resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
|
||||
|
||||
gtoken@8.0.0:
|
||||
resolution: {integrity: sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
gulp-sort@2.0.0:
|
||||
resolution: {integrity: sha512-MyTel3FXOdh1qhw1yKhpimQrAmur9q1X0ZigLmCOxouQD+BD3za9/89O+HfbgBQvvh4igEbp0/PUWO+VqGYG1g==}
|
||||
|
||||
@@ -4955,6 +5013,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-bigint@1.0.0:
|
||||
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
|
||||
|
||||
json-buffer@3.0.1:
|
||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||
|
||||
@@ -4993,6 +5054,12 @@ packages:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.0:
|
||||
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
|
||||
|
||||
jwt-decode@4.0.0:
|
||||
resolution: {integrity: sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -5293,6 +5360,10 @@ packages:
|
||||
encoding:
|
||||
optional: true
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
|
||||
node-releases@2.0.18:
|
||||
resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==}
|
||||
|
||||
@@ -6507,6 +6578,9 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
url-template@2.0.8:
|
||||
resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
|
||||
|
||||
urlpattern-polyfill@10.0.0:
|
||||
resolution: {integrity: sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==}
|
||||
|
||||
@@ -6645,6 +6719,10 @@ packages:
|
||||
resolution: {integrity: sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
web-streams-polyfill@3.3.3:
|
||||
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3:
|
||||
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||
engines: {node: '>= 14'}
|
||||
@@ -11346,6 +11424,8 @@ snapshots:
|
||||
|
||||
big.js@5.2.2: {}
|
||||
|
||||
bignumber.js@9.3.1: {}
|
||||
|
||||
binary-extensions@2.3.0: {}
|
||||
|
||||
bl@5.1.0:
|
||||
@@ -11399,6 +11479,8 @@ snapshots:
|
||||
node-releases: 2.0.19
|
||||
update-browserslist-db: 1.1.1(browserslist@4.24.4)
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@6.0.3:
|
||||
@@ -11640,6 +11722,8 @@ snapshots:
|
||||
|
||||
damerau-levenshtein@1.0.8: {}
|
||||
|
||||
data-uri-to-buffer@4.0.1: {}
|
||||
|
||||
data-urls@5.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 4.0.0
|
||||
@@ -11761,6 +11845,10 @@ snapshots:
|
||||
|
||||
eastasianwidth@0.2.0: {}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
eciesjs@0.4.14:
|
||||
dependencies:
|
||||
'@ecies/ciphers': 0.2.3(@noble/ciphers@1.2.1)
|
||||
@@ -12236,6 +12324,8 @@ snapshots:
|
||||
|
||||
exsolve@1.0.7: {}
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
@@ -12284,6 +12374,11 @@ snapshots:
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.3
|
||||
|
||||
fetch-blob@3.2.0:
|
||||
dependencies:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 3.3.3
|
||||
|
||||
fflate@0.4.8: {}
|
||||
|
||||
fflate@0.8.2: {}
|
||||
@@ -12354,6 +12449,10 @@ snapshots:
|
||||
node-domexception: 1.0.0
|
||||
web-streams-polyfill: 4.0.0-beta.3
|
||||
|
||||
formdata-polyfill@4.0.10:
|
||||
dependencies:
|
||||
fetch-blob: 3.2.0
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
fraction.js@4.3.7: {}
|
||||
@@ -12400,6 +12499,22 @@ snapshots:
|
||||
|
||||
functions-have-names@1.2.3: {}
|
||||
|
||||
gaxios@7.1.2:
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
https-proxy-agent: 7.0.6
|
||||
node-fetch: 3.3.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
gcp-metadata@8.1.1:
|
||||
dependencies:
|
||||
gaxios: 7.1.2
|
||||
google-logging-utils: 1.1.1
|
||||
json-bigint: 1.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
gensync@1.0.0-beta.2: {}
|
||||
|
||||
get-caller-file@2.0.5: {}
|
||||
@@ -12529,6 +12644,37 @@ snapshots:
|
||||
|
||||
globrex@0.1.2: {}
|
||||
|
||||
google-auth-library@10.4.1:
|
||||
dependencies:
|
||||
base64-js: 1.5.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
gaxios: 7.1.2
|
||||
gcp-metadata: 8.1.1
|
||||
google-logging-utils: 1.1.1
|
||||
gtoken: 8.0.0
|
||||
jws: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
google-logging-utils@1.1.1: {}
|
||||
|
||||
googleapis-common@8.0.0:
|
||||
dependencies:
|
||||
extend: 3.0.2
|
||||
gaxios: 7.1.2
|
||||
google-auth-library: 10.4.1
|
||||
qs: 6.14.0
|
||||
url-template: 2.0.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
googleapis@164.1.0:
|
||||
dependencies:
|
||||
google-auth-library: 10.4.1
|
||||
googleapis-common: 8.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
gopd@1.1.0:
|
||||
dependencies:
|
||||
get-intrinsic: 1.3.0
|
||||
@@ -12539,6 +12685,13 @@ snapshots:
|
||||
|
||||
graphemer@1.4.0: {}
|
||||
|
||||
gtoken@8.0.0:
|
||||
dependencies:
|
||||
gaxios: 7.1.2
|
||||
jws: 4.0.0
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
gulp-sort@2.0.0:
|
||||
dependencies:
|
||||
through2: 2.0.5
|
||||
@@ -12907,6 +13060,10 @@ snapshots:
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-bigint@1.0.0:
|
||||
dependencies:
|
||||
bignumber.js: 9.3.1
|
||||
|
||||
json-buffer@3.0.1: {}
|
||||
|
||||
json-parse-even-better-errors@2.3.1: {}
|
||||
@@ -12940,6 +13097,17 @@ snapshots:
|
||||
object.assign: 4.1.5
|
||||
object.values: 1.2.0
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.0:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jwt-decode@4.0.0: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
@@ -13192,6 +13360,12 @@ snapshots:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
|
||||
node-fetch@3.3.2:
|
||||
dependencies:
|
||||
data-uri-to-buffer: 4.0.1
|
||||
fetch-blob: 3.2.0
|
||||
formdata-polyfill: 4.0.10
|
||||
|
||||
node-releases@2.0.18: {}
|
||||
|
||||
node-releases@2.0.19: {}
|
||||
@@ -14469,6 +14643,8 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
url-template@2.0.8: {}
|
||||
|
||||
urlpattern-polyfill@10.0.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
@@ -14626,6 +14802,8 @@ snapshots:
|
||||
glob-to-regexp: 0.4.1
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
web-streams-polyfill@3.3.3: {}
|
||||
|
||||
web-streams-polyfill@4.0.0-beta.3: {}
|
||||
|
||||
web-vitals@4.2.4: {}
|
||||
|
||||
Reference in New Issue
Block a user