feat: support Android IAP (#2286)

This commit is contained in:
Huang Xin
2025-10-22 00:24:50 +08:00
committed by GitHub
parent 28ef414c3d
commit 8737535b90
21 changed files with 1439 additions and 93 deletions
+3 -1
View File
@@ -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")
@@ -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()
@@ -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}
+19 -16
View File
@@ -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;
+1
View File
@@ -54,6 +54,7 @@ export interface AppService {
hasUpdater: boolean;
hasOrientationLock: boolean;
hasScreenBrightness: boolean;
hasIAP: boolean;
isMobile: boolean;
isAppDataSandbox: boolean;
isMobileApp: boolean;
+22 -4
View File
@@ -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;
}
+178
View File
@@ -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: {}