From 8737535b90618d0c2ef21f213e94cce9e63a0ca0 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Wed, 22 Oct 2025 00:24:50 +0800 Subject: [PATCH] feat: support Android IAP (#2286) --- apps/readest-app/package.json | 4 +- .../android/app/src/main/AndroidManifest.xml | 1 + .../android/build.gradle.kts | 1 + .../android/src/main/java/BillingManager.kt | 283 +++++++++++++++ .../src/main/java/NativeBridgePlugin.kt | 150 +++++++- .../tauri-plugin-native-bridge/src/models.rs | 13 +- .../src/__tests__/api/iap-verify.test.ts | 34 +- .../src/__tests__/helpers/supabase-mock.ts | 5 +- .../__tests__/integration/apple-iap.test.ts | 4 +- .../src/app/api/google/iap-verify/route.ts | 332 ++++++++++++++++++ .../app/user/components/AccountActions.tsx | 2 +- .../app/user/components/PlansComparison.tsx | 2 +- apps/readest-app/src/app/user/page.tsx | 35 +- .../app/user/subscription/success/page.tsx | 163 ++++++--- apps/readest-app/src/components/Providers.tsx | 9 +- .../src/libs/iap/google/verifier.ts | 281 +++++++++++++++ apps/readest-app/src/services/appService.ts | 1 + .../src/services/nativeAppService.ts | 7 +- apps/readest-app/src/types/system.ts | 1 + apps/readest-app/src/utils/iap.ts | 26 +- pnpm-lock.yaml | 178 ++++++++++ 21 files changed, 1439 insertions(+), 93 deletions(-) create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/BillingManager.kt create mode 100644 apps/readest-app/src/app/api/google/iap-verify/route.ts create mode 100644 apps/readest-app/src/libs/iap/google/verifier.ts diff --git a/apps/readest-app/package.json b/apps/readest-app/package.json index 14de8222..fec301bb 100644 --- a/apps/readest-app/package.json +++ b/apps/readest-app/package.json @@ -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", diff --git a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml index 3b96dd68..064c1375 100644 --- a/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/apps/readest-app/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,7 @@ + diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts index ad6ed780..f864ee86 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts @@ -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") diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/BillingManager.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/BillingManager.kt new file mode 100644 index 00000000..666fd052 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/BillingManager.kt @@ -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() + 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, callback: (List) -> Unit) { + if (!::billingClient.isInitialized || !billingClient.isReady) { + Log.e(TAG, "Billing client not ready") + callback(emptyList()) + return + } + + scope.launch { + val products = mutableListOf() + + // 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, + productType: String, + callback: (List) -> 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) -> Unit) { + if (!::billingClient.isInitialized || !billingClient.isReady) { + Log.e(TAG, "Billing client not ready") + callback(emptyList()) + return + } + + scope.launch { + val allPurchases = mutableListOf() + + // 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?) { + 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, + ) + } +} \ No newline at end of file diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt index 04b5159e..bfb0e4ec 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt @@ -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? = 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() diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index 7649eef3..03e33ff6 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -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, + pub product_id: String, + pub transaction_id: Option, + pub original_transaction_id: Option, + pub order_id: Option, + pub purchase_token: Option, + pub purchase_date: String, + pub purchase_state: String, // "purchased", "pending", "cancelled", "restored" } #[derive(Debug, Serialize, Deserialize)] diff --git a/apps/readest-app/src/__tests__/api/iap-verify.test.ts b/apps/readest-app/src/__tests__/api/iap-verify.test.ts index bf9b6df5..117284c1 100644 --- a/apps/readest-app/src/__tests__/api/iap-verify.test.ts +++ b/apps/readest-app/src/__tests__/api/iap-verify.test.ts @@ -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); diff --git a/apps/readest-app/src/__tests__/helpers/supabase-mock.ts b/apps/readest-app/src/__tests__/helpers/supabase-mock.ts index eaf8eeda..efeb93da 100644 --- a/apps/readest-app/src/__tests__/helpers/supabase-mock.ts +++ b/apps/readest-app/src/__tests__/helpers/supabase-mock.ts @@ -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, diff --git a/apps/readest-app/src/__tests__/integration/apple-iap.test.ts b/apps/readest-app/src/__tests__/integration/apple-iap.test.ts index bb02b046..c299e05b 100644 --- a/apps/readest-app/src/__tests__/integration/apple-iap.test.ts +++ b/apps/readest-app/src/__tests__/integration/apple-iap.test.ts @@ -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']); diff --git a/apps/readest-app/src/app/api/google/iap-verify/route.ts b/apps/readest-app/src/app/api/google/iap-verify/route.ts new file mode 100644 index 00000000..941457d5 --- /dev/null +++ b/apps/readest-app/src/app/api/google/iap-verify/route.ts @@ -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 = { + '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 }, + ); + } +} diff --git a/apps/readest-app/src/app/user/components/AccountActions.tsx b/apps/readest-app/src/app/user/components/AccountActions.tsx index b721e86f..73ebc5c0 100644 --- a/apps/readest-app/src/app/user/components/AccountActions.tsx +++ b/apps/readest-app/src/app/user/components/AccountActions.tsx @@ -85,7 +85,7 @@ const AccountActions: React.FC = ({ }} />
- {appService?.isIOSApp ? ( + {appService?.hasIAP ? (
diff --git a/apps/readest-app/src/app/user/subscription/success/page.tsx b/apps/readest-app/src/app/user/subscription/success/page.tsx index 9ef62b33..547b7c18 100644 --- a/apps/readest-app/src/app/user/subscription/success/page.tsx +++ b/apps/readest-app/src/app/user/subscription/success/page.tsx @@ -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' })); } diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index 341ffba6..3c503bc7 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -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; diff --git a/apps/readest-app/src/libs/iap/google/verifier.ts b/apps/readest-app/src/libs/iap/google/verifier.ts new file mode 100644 index 00000000..29d05955 --- /dev/null +++ b/apps/readest-app/src/libs/iap/google/verifier.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/apps/readest-app/src/services/appService.ts b/apps/readest-app/src/services/appService.ts index f8910f66..2f4cf76b 100644 --- a/apps/readest-app/src/services/appService.ts +++ b/apps/readest-app/src/services/appService.ts @@ -88,6 +88,7 @@ export abstract class BaseAppService implements AppService { hasUpdater = false; hasOrientationLock = false; hasScreenBrightness = false; + hasIAP = false; canCustomizeRootDir = false; distChannel = 'readest' as DistChannel; diff --git a/apps/readest-app/src/services/nativeAppService.ts b/apps/readest-app/src/services/nativeAppService.ts index 757d8318..df23d829 100644 --- a/apps/readest-app/src/services/nativeAppService.ts +++ b/apps/readest-app/src/services/nativeAppService.ts @@ -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; diff --git a/apps/readest-app/src/types/system.ts b/apps/readest-app/src/types/system.ts index 7de7b7d6..e6b34336 100644 --- a/apps/readest-app/src/types/system.ts +++ b/apps/readest-app/src/types/system.ts @@ -54,6 +54,7 @@ export interface AppService { hasUpdater: boolean; hasOrientationLock: boolean; hasScreenBrightness: boolean; + hasIAP: boolean; isMobile: boolean; isAppDataSandbox: boolean; isMobileApp: boolean; diff --git a/apps/readest-app/src/utils/iap.ts b/apps/readest-app/src/utils/iap.ts index a2487bbb..cb715feb 100644 --- a/apps/readest-app/src/utils/iap.ts +++ b/apps/readest-app/src/utils/iap.ts @@ -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; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 58cd0b12..177eff84 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: {}