diff --git a/.claude/commands/audit-code.md b/.claude/commands/audit-code.md index 02ab33d38..d829146bd 100644 --- a/.claude/commands/audit-code.md +++ b/.claude/commands/audit-code.md @@ -1,3 +1,8 @@ +--- +name: audit-code +description: Audit OpenIAP code against the knowledge-base rules and current platform APIs, then fix the violations it finds. Use when the user asks to audit code, check convention compliance, or verify sources against knowledge/internal. +--- + # Audit Code Against Knowledge Rules Automated workflow to check and fix code based on knowledge rules and latest platform APIs. diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md index 5fdb37984..9230f5d5c 100644 --- a/.claude/commands/commit.md +++ b/.claude/commands/commit.md @@ -1,3 +1,8 @@ +--- +name: commit +description: Branch, commit, push, and optionally open a pull request for the current changes. Use when the user asks to commit, push, or create a PR, including forms like `/commit --all --pr`. +--- + # Commit Changes Complete workflow: branch → commit → push → PR diff --git a/.claude/commands/compile-knowledge.md b/.claude/commands/compile-knowledge.md index 614a96ee9..df28bac3e 100644 --- a/.claude/commands/compile-knowledge.md +++ b/.claude/commands/compile-knowledge.md @@ -1,3 +1,8 @@ +--- +name: compile-knowledge +description: Compile the OpenIAP knowledge base into the context files AI assistants load. Use after editing anything under knowledge/, or when the user asks to compile, recompile, or refresh the knowledge base or agent context. +--- + # Compile Knowledge Base Compile the OpenIAP knowledge base to generate context files for AI assistants. diff --git a/.claude/commands/e2e-tests.md b/.claude/commands/e2e-tests.md index d2e4a1e17..5ad85fda7 100644 --- a/.claude/commands/e2e-tests.md +++ b/.claude/commands/e2e-tests.md @@ -1,3 +1,8 @@ +--- +name: e2e-tests +description: Run device-backed OpenIAP regression across native packages and framework examples using real devices and store accounts. Use when the user asks for e2e tests, device testing, store purchase-flow verification, or a full PR regression on hardware. +--- + # E2E Tests — Device-Backed OpenIAP Regression Run this when a PR or release candidate needs real-device regression across @@ -607,8 +612,10 @@ FireOS/Amazon Android build and launch smoke: cd libraries/flutter_inapp_purchase/example/android ./gradlew :app:assembleDebug -PfireOsEnabled=true # Build-only regression can stop here. +# Flutter redirects its gradle output to `example/build/app/outputs/flutter-apk/`, +# so this path is not the `android/app/build/...` layout the other examples use. : "${FIREOS_SERIAL:?Set FIREOS_SERIAL to the target FireOS device serial}" -adb -s "$FIREOS_SERIAL" install -r app/build/outputs/apk/debug/app-debug.apk +adb -s "$FIREOS_SERIAL" install -r ../build/app/outputs/flutter-apk/app-debug.apk adb -s "$FIREOS_SERIAL" shell monkey -p dev.hyo.martie 1 ``` diff --git a/.claude/commands/release.md b/.claude/commands/release.md index 700cc70a2..8279bae7b 100644 --- a/.claude/commands/release.md +++ b/.claude/commands/release.md @@ -1,3 +1,8 @@ +--- +name: release +description: Release OpenIAP packages (stable or prerelease) one at a time, verifying the public registry before continuing. Use when the user asks to release, publish, or deploy a package or run a release train. +--- + # Release Packages Use this workflow for stable or prerelease package deployment. Release one diff --git a/.claude/commands/resolve-issue.md b/.claude/commands/resolve-issue.md index a77f3f816..b2eee2500 100644 --- a/.claude/commands/resolve-issue.md +++ b/.claude/commands/resolve-issue.md @@ -1,3 +1,8 @@ +--- +name: resolve-issue +description: Analyze a GitHub issue, apply labels, then either fix it with a PR or reply with the analysis. Use when the user names an issue number and asks to resolve, triage, fix, or investigate it. +--- + # Resolve Issue Analyze a GitHub issue, add labels, and either fix it with a PR or comment with analysis. diff --git a/.claude/commands/review-pr.md b/.claude/commands/review-pr.md index 0e6699d92..9b642966d 100644 --- a/.claude/commands/review-pr.md +++ b/.claude/commands/review-pr.md @@ -1,3 +1,8 @@ +--- +name: review-pr +description: Review and address pull-request review comments by fixing valid findings, replying to invalid ones, running lint/typecheck/tests, then resolving the threads. Use when the user asks to review a PR, handle review feedback, or address reviewer comments on a pull request. +--- + # Review PR Comments Review and address PR review comments for this repository. diff --git a/.claude/commands/verify-all.md b/.claude/commands/verify-all.md index 76c82a082..719ff1f7b 100644 --- a/.claude/commands/verify-all.md +++ b/.claude/commands/verify-all.md @@ -1,3 +1,8 @@ +--- +name: verify-all +description: Run the full monorepo health check across every package and library. Use before committing or opening a PR, or when the user asks to verify everything, run all checks, or confirm the repo is healthy. +--- + # Verify All — Full Monorepo Health Check Run this before committing or creating a PR to verify the entire monorepo is healthy. diff --git a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt index d30d8d69e..9e11f929a 100644 --- a/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt +++ b/libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt @@ -27,6 +27,7 @@ import expo.modules.kotlin.Promise import expo.modules.kotlin.exception.Exceptions import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -58,6 +59,20 @@ internal suspend fun endExpoConnectionWithCleanup( internal fun endConnectionErrorCode(error: Exception): String = (error as? OpenIapError)?.code ?: OpenIapError.ServiceDisconnected.CODE +internal fun deliverPurchaseRequestFailure( + reachedOpenIapRequest: Boolean, + isCancellation: Boolean = false, + errorCode: String, + errorEnvelope: String, + emitLocalError: () -> Unit, + rejectPendingPromises: (String, String) -> Unit, +) { + if (!reachedOpenIapRequest && !isCancellation) { + emitLocalError() + } + rejectPendingPromises(errorCode, errorEnvelope) +} + class ExpoIapModule : Module() { companion object { const val TAG = "ExpoIapModule" @@ -387,9 +402,11 @@ class ExpoIapModule : Module() { ExpoIapHelper.addPurchasePromise(promise) scope.launch { + var reachedOpenIapRequest = false try { val activity = currentActivity openIap.setActivity(activity) + reachedOpenIapRequest = true val result = openIap.requestPurchase(requestProps) val purchases = when (result) { @@ -415,23 +432,33 @@ class ExpoIapModule : Module() { ) } val errorCode = errorMap["code"] as? String ?: OpenIapError.PurchaseFailed.CODE - runCatching { - ExpoIapHelper.emitOrQueue( - this@ExpoIapModule, - scope, - connectionReady, - pendingEvents, - EVENT_PURCHASE_ERROR, - errorMap, - ) - }.onFailure { ex -> - ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex) - } - ExpoIapHelper.rejectPurchasePromises( - errorCode, - ExpoIapHelper.serializeErrorEnvelope(errorMap), - null, + val errorEnvelope = ExpoIapHelper.serializeErrorEnvelope(errorMap) + deliverPurchaseRequestFailure( + reachedOpenIapRequest = reachedOpenIapRequest, + isCancellation = e is CancellationException, + errorCode = errorCode, + errorEnvelope = errorEnvelope, + emitLocalError = { + runCatching { + ExpoIapHelper.emitOrQueue( + this@ExpoIapModule, + scope, + connectionReady, + pendingEvents, + EVENT_PURCHASE_ERROR, + errorMap, + ) + }.onFailure { ex -> + ExpoIapLog.failure("send PURCHASE_ERROR event requestPurchase", ex) + } + }, + rejectPendingPromises = { code, message -> + ExpoIapHelper.rejectPurchasePromises(code, message, null) + }, ) + if (e is CancellationException) { + throw e + } } } } diff --git a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt index 16420fdfa..c1e4dec9a 100644 --- a/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt +++ b/libraries/expo-iap/android/src/test/java/expo/modules/iap/ExpoIapHelperTest.kt @@ -137,6 +137,58 @@ class ExpoIapHelperTest { assertEquals(OpenIapError.NetworkError.CODE, endConnectionErrorCode(OpenIapError.NetworkError)) } + @Test + fun `purchase failure before core emits and rejects`() { + var emitted = 0 + var rejected: Pair? = null + + deliverPurchaseRequestFailure( + reachedOpenIapRequest = false, + errorCode = "purchase-error", + errorEnvelope = "envelope", + emitLocalError = { emitted += 1 }, + rejectPendingPromises = { code, message -> rejected = code to message }, + ) + + assertEquals(1, emitted) + assertEquals("purchase-error" to "envelope", rejected) + } + + @Test + fun `purchase failure after core suppresses duplicate event but still rejects`() { + var emitted = 0 + var rejected: Pair? = null + + deliverPurchaseRequestFailure( + reachedOpenIapRequest = true, + errorCode = "user-cancelled", + errorEnvelope = "envelope", + emitLocalError = { emitted += 1 }, + rejectPendingPromises = { code, message -> rejected = code to message }, + ) + + assertEquals(0, emitted) + assertEquals("user-cancelled" to "envelope", rejected) + } + + @Test + fun `purchase coroutine cancellation rejects without publishing a purchase error`() { + var emitted = 0 + var rejected: Pair? = null + + deliverPurchaseRequestFailure( + reachedOpenIapRequest = false, + isCancellation = true, + errorCode = "service-disconnected", + errorEnvelope = "envelope", + emitLocalError = { emitted += 1 }, + rejectPendingPromises = { code, message -> rejected = code to message }, + ) + + assertEquals(0, emitted) + assertEquals("service-disconnected" to "envelope", rejected) + } + @Test fun `end connection falls back to service disconnected`() { assertEquals( diff --git a/libraries/expo-iap/ios/ExpoIapHelper.swift b/libraries/expo-iap/ios/ExpoIapHelper.swift index 8cf73ae85..8eb2d3667 100644 --- a/libraries/expo-iap/ios/ExpoIapHelper.swift +++ b/libraries/expo-iap/ios/ExpoIapHelper.swift @@ -64,6 +64,33 @@ enum ExpoIapHelper { array } + // Keep Expo IAP compatible with the currently published OpenIAP native + // package while treating authoritative query serialization atomically. + // Its non-throwing helpers use an empty dictionary as the failure sentinel. + static func encodeRequired(_ value: T) throws -> [String: Any] { + let encoded = OpenIapSerialization.encode(value) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + return encoded + } + + static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] { + try purchases.map { purchase in + let encoded = OpenIapSerialization.purchase(purchase) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native purchase payload" + ) + } + return encoded + } + } + static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType { guard let raw = rawValue?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else { diff --git a/libraries/expo-iap/ios/ExpoIapModule.swift b/libraries/expo-iap/ios/ExpoIapModule.swift index 48b3e5733..ddcc90892 100644 --- a/libraries/expo-iap/ios/ExpoIapModule.swift +++ b/libraries/expo-iap/ios/ExpoIapModule.swift @@ -142,7 +142,7 @@ public final class ExpoIapModule: Module { ExpoIapLog.payload("getAvailablePurchases", payload: options ?? [:]) let purchaseOptions = try options.map { try OpenIapSerialization.purchaseOptions(from: $0) } let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions) - let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let sanitized = ExpoIapHelper.sanitizeArray(try ExpoIapHelper.purchasesRequired(purchases)) ExpoIapLog.result("getAvailablePurchases", value: sanitized) return sanitized } @@ -162,7 +162,7 @@ public final class ExpoIapModule: Module { ] let options = try OpenIapSerialization.purchaseOptions(from: optionsDictionary) let purchases = try await OpenIapModule.shared.getAvailablePurchases(options) - let sanitized = ExpoIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let sanitized = ExpoIapHelper.sanitizeArray(try ExpoIapHelper.purchasesRequired(purchases)) ExpoIapLog.result("getAvailableItems", value: sanitized) return sanitized } @@ -170,7 +170,9 @@ public final class ExpoIapModule: Module { AsyncFunction("getPendingTransactionsIOS") { () async throws -> [[String: Any]] in ExpoIapLog.payload("getPendingTransactionsIOS", payload: nil) let pending = try await OpenIapModule.shared.getPendingTransactionsIOS() - let sanitized = pending.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) } + let sanitized = try pending.map { + ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0)) + } ExpoIapLog.result("getPendingTransactionsIOS", value: sanitized) return sanitized } @@ -178,7 +180,9 @@ public final class ExpoIapModule: Module { AsyncFunction("getAllTransactionsIOS") { () async throws -> [[String: Any]] in ExpoIapLog.payload("getAllTransactionsIOS", payload: nil) let all = try await OpenIapModule.shared.getAllTransactionsIOS() - let sanitized = all.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) } + let sanitized = try all.map { + ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0)) + } ExpoIapLog.result("getAllTransactionsIOS", value: sanitized) return sanitized } @@ -270,7 +274,9 @@ public final class ExpoIapModule: Module { AsyncFunction("showManageSubscriptionsIOS") { () async throws -> [[String: Any]] in ExpoIapLog.payload("showManageSubscriptionsIOS", payload: nil) let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS() - let sanitized = purchases.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) } + let sanitized = try purchases.map { + ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0)) + } ExpoIapLog.result("showManageSubscriptionsIOS", value: sanitized) return sanitized } @@ -384,7 +390,9 @@ public final class ExpoIapModule: Module { AsyncFunction("getActiveSubscriptions") { (subscriptionIds: [String]?) async throws -> [[String: Any]] in ExpoIapLog.payload("getActiveSubscriptions", payload: subscriptionIds.map { ["subscriptionIds": $0] } ?? [:]) let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds) - let sanitized = subscriptions.map { ExpoIapHelper.sanitizeDictionary(OpenIapSerialization.encode($0)) } + let sanitized = try subscriptions.map { + ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0)) + } ExpoIapLog.result("getActiveSubscriptions", value: sanitized) return sanitized } diff --git a/libraries/expo-iap/src/__tests__/index.kepler.test.ts b/libraries/expo-iap/src/__tests__/index.kepler.test.ts index 438d60278..899a5ab51 100644 --- a/libraries/expo-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/expo-iap/src/__tests__/index.kepler.test.ts @@ -1,5 +1,6 @@ import { fetchProducts, + getAvailablePurchases, openRedeemOfferCodeAndroid, requestPurchase, } from '../index.kepler'; @@ -11,12 +12,14 @@ jest.mock('../vega', () => ({ describe('Amazon Vega public API', () => { const fetchProductsNative = jest.fn().mockResolvedValue([]); + const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]); const requestPurchaseNative = jest.fn().mockResolvedValue([]); beforeEach(() => { jest.clearAllMocks(); (getVegaIapModule as jest.Mock).mockReturnValue({ fetchProducts: fetchProductsNative, + getAvailableItems: getAvailablePurchasesNative, requestPurchase: requestPurchaseNative, }); }); @@ -67,4 +70,23 @@ describe('Amazon Vega public API', () => { expect(requestPurchaseNative).not.toHaveBeenCalled(); }); + + it('rejects an Apple purchase returned by the Vega bridge', async () => { + getAvailablePurchasesNative.mockResolvedValueOnce([ + { + id: 'foreign', + transactionId: 'foreign', + productId: 'premium', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + ]); + + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); }); diff --git a/libraries/expo-iap/src/__tests__/index.test.ts b/libraries/expo-iap/src/__tests__/index.test.ts index 85701acc7..40ada0ba2 100644 --- a/libraries/expo-iap/src/__tests__/index.test.ts +++ b/libraries/expo-iap/src/__tests__/index.test.ts @@ -43,6 +43,20 @@ import {Platform} from 'react-native'; const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); +const nativePurchase = ( + id: string, + overrides: Record = {}, +) => ({ + id, + productId: `product.${id}`, + transactionDate: 1720000000000, + store: 'google', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + ...overrides, +}); + afterEach(() => { consoleLogSpy.mockClear(); }); @@ -1168,8 +1182,8 @@ describe('Public API (index.ts)', () => { (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValueOnce([ - {id: 'p1', transactionId: 'txn-1'}, - {id: 's1', transactionId: 'txn-2'}, + nativePurchase('p1', {transactionId: 'txn-1'}), + nativePurchase('s1', {transactionId: 'txn-2'}), ]); const res = await getAvailablePurchases(); expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith({ @@ -1187,12 +1201,11 @@ describe('Public API (index.ts)', () => { (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() .mockResolvedValueOnce([ - {id: 'active-sub', transactionId: 'txn-1'}, - { - id: 'suspended-sub', + nativePurchase('active-sub', {transactionId: 'txn-1'}), + nativePurchase('suspended-sub', { transactionId: 'txn-2', isSuspendedAndroid: true, - }, + }), ]); const res = await getAvailablePurchases({includeSuspendedAndroid: true}); expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith({ @@ -1208,10 +1221,15 @@ describe('Public API (index.ts)', () => { (Platform as any).select = (obj: any) => obj.ios; const syncSpy = jest .spyOn(iosMod as any, 'syncIOS') - .mockResolvedValue(undefined as any); + .mockResolvedValue(true); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() - .mockResolvedValue([{id: 'legacy', transactionId: 'txn-restore'}]); + .mockResolvedValue([ + nativePurchase('legacy', { + store: 'apple', + transactionId: 'txn-restore', + }), + ]); await restorePurchases(); expect(syncSpy).toHaveBeenCalledTimes(1); expect(ExpoIapModule.getAvailableItems).toHaveBeenCalledWith(false, true); @@ -1222,7 +1240,7 @@ describe('Public API (index.ts)', () => { (Platform as any).select = (obj: any) => obj.ios; const syncSpy = jest .spyOn(iosMod as any, 'syncIOS') - .mockResolvedValue(undefined as any); + .mockResolvedValue(true); Object.defineProperty(ExpoIapModule, 'USING_ONSIDE_SDK', { configurable: true, value: true, @@ -1232,7 +1250,12 @@ describe('Public API (index.ts)', () => { .mockResolvedValue(true); (ExpoIapModule.getAvailableItems as jest.Mock) = jest .fn() - .mockResolvedValue([{id: 'onside', transactionId: 'txn-onside'}]); + .mockResolvedValue([ + nativePurchase('onside', { + store: 'apple', + transactionId: 'txn-onside', + }), + ]); try { await restorePurchases(); @@ -1247,6 +1270,70 @@ describe('Public API (index.ts)', () => { delete (ExpoIapModule as any).USING_ONSIDE_SDK; } }); + + it('getAvailablePurchases rejects mixed malformed results atomically', async () => { + (Platform as any).OS = 'android'; + (ExpoIapModule.getAvailableItems as jest.Mock) = jest + .fn() + .mockResolvedValue([nativePurchase('valid'), {id: 'malformed'}]); + + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }); + + it('getAvailablePurchases rejects a foreign store on iOS', async () => { + (Platform as any).OS = 'ios'; + (ExpoIapModule.getAvailableItems as jest.Mock) = jest + .fn() + .mockResolvedValue([ + nativePurchase('foreign', { + store: 'google', + transactionId: 'foreign', + }), + ]); + + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }); + + it('getAvailablePurchases rejects a foreign store on Android', async () => { + (Platform as any).OS = 'android'; + (ExpoIapModule.getAvailableItems as jest.Mock) = jest + .fn() + .mockResolvedValue([ + nativePurchase('foreign', { + store: 'apple', + transactionId: 'foreign', + }), + ]); + + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }); + + it('restorePurchases propagates iOS sync failure without querying', async () => { + (Platform as any).OS = 'ios'; + const syncError = new Error('sync failed'); + jest.spyOn(iosMod as any, 'syncIOS').mockRejectedValue(syncError); + (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); + + await expect(restorePurchases()).rejects.toBe(syncError); + expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled(); + }); + + it('restorePurchases rejects a false iOS sync result', async () => { + (Platform as any).OS = 'ios'; + jest.spyOn(iosMod as any, 'syncIOS').mockResolvedValue(false); + (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); + + await expect(restorePurchases()).rejects.toMatchObject({ + code: ErrorCode.SyncError, + }); + expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled(); + }); }); describe('finishTransaction', () => { diff --git a/libraries/expo-iap/src/__tests__/useIAP.test.tsx b/libraries/expo-iap/src/__tests__/useIAP.test.tsx index b71406070..a45309a35 100644 --- a/libraries/expo-iap/src/__tests__/useIAP.test.tsx +++ b/libraries/expo-iap/src/__tests__/useIAP.test.tsx @@ -69,6 +69,7 @@ describe('useIAP hook', () => { (ExpoIapModule.endConnection as jest.Mock) = jest .fn() .mockResolvedValue(true); + (ExpoIapModule.syncIOS as jest.Mock) = jest.fn().mockResolvedValue(true); (ExpoIapModule.addListener as jest.Mock) = jest.fn().mockReturnValue({ remove: jest.fn(), }); @@ -282,6 +283,41 @@ describe('useIAP hook', () => { expect(onError).toHaveBeenCalledWith(mockError); }); + it('calls onError and rethrows when hasActiveSubscriptions fails', async () => { + const mockError = new Error('Entitlement status unavailable'); + (ExpoIapModule.hasActiveSubscriptions as jest.Mock) = jest + .fn() + .mockRejectedValue(mockError); + + const onError = jest.fn(); + let hookResult: ReturnType | null = null; + + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create( + { + hookResult = hook; + }} + />, + ); + await flushPromises(); + }); + + let thrown: unknown; + await ReactTestRenderer.act(async () => { + try { + await hookResult!.hasActiveSubscriptions(); + } catch (error) { + thrown = error; + } + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(mockError); + expect(thrown).toBe(mockError); + }); + it('calls onError when restorePurchases fails', async () => { const mockError = new Error('Restore failed'); (ExpoIapModule.getAvailableItems as jest.Mock) = jest @@ -319,6 +355,36 @@ describe('useIAP hook', () => { expect(onError).toHaveBeenCalledWith(mockError); }); + it('stops restore after an iOS sync rejection', async () => { + const mockError = new Error('App Store sync failed'); + (ExpoIapModule.syncIOS as jest.Mock) = jest + .fn() + .mockRejectedValue(mockError); + (ExpoIapModule.getAvailableItems as jest.Mock) = jest.fn(); + + const onError = jest.fn(); + let hookResult: ReturnType | null = null; + await ReactTestRenderer.act(async () => { + ReactTestRenderer.create( + { + hookResult = hook; + }} + />, + ); + await flushPromises(); + }); + + await ReactTestRenderer.act(async () => { + await expect(hookResult!.restorePurchases()).rejects.toBe(mockError); + }); + + expect(ExpoIapModule.getAvailableItems).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(mockError); + }); + it('does not call onError when fetchProducts succeeds', async () => { (ExpoIapModule.fetchProducts as jest.Mock) = jest .fn() diff --git a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts index 4bd4a5161..5ee96feab 100644 --- a/libraries/expo-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/expo-iap/src/__tests__/vega-adapter.test.ts @@ -65,7 +65,7 @@ const createService = (): jest.Mocked => notifyFulfillment: jest.fn(async () => ({ responseCode: 1, })), - }) as unknown as jest.Mocked; + } as unknown as jest.Mocked); describe('Amazon Vega Expo adapter', () => { it('initializes without fetching Amazon user data', async () => { @@ -893,7 +893,7 @@ describe('Amazon Vega Expo adapter', () => { ]); }); - it('treats Amazon parser-only purchase update errors as no updates', async () => { + it('rejects Amazon parser-only purchase update errors atomically', async () => { const service = createService(); service.getPurchaseUpdates.mockRejectedValueOnce( new Error( @@ -902,7 +902,35 @@ describe('Amazon Vega Expo adapter', () => { ); const module = createExpoIapVegaModule(service); - await expect(module.getAvailableItems()).resolves.toEqual([]); + await expect(module.getAvailableItems()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }); + + it('rejects all pages when a later Amazon purchase update page is malformed', async () => { + const service = createService(); + service.getPurchaseUpdates + .mockResolvedValueOnce({ + responseCode: 1, + hasMore: true, + receiptList: [ + { + receiptId: 'receipt-page-1', + sku: 'coins_100', + productType: 1, + }, + ], + }) + .mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createExpoIapVegaModule(service); + + await expect(module.getAvailableItems()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); }); it('retries failed Amazon purchase update responses', async () => { @@ -942,7 +970,7 @@ describe('Amazon Vega Expo adapter', () => { } }); - it('ignores parser-only product type hydration errors for purchase updates', async () => { + it('rejects parser-only product type hydration errors for purchase updates', async () => { const service = createService(); service.getPurchaseUpdates.mockResolvedValueOnce({ responseCode: 1, @@ -963,7 +991,9 @@ describe('Amazon Vega Expo adapter', () => { await expect( module.getActiveSubscriptions(['premium_monthly']), - ).resolves.toEqual([]); + ).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); }); it('limits paginated Amazon purchase updates', async () => { diff --git a/libraries/expo-iap/src/index.kepler.ts b/libraries/expo-iap/src/index.kepler.ts index f3c41de52..17d6420c7 100644 --- a/libraries/expo-iap/src/index.kepler.ts +++ b/libraries/expo-iap/src/index.kepler.ts @@ -12,6 +12,7 @@ import type { RequestPurchasePropsByPlatforms, RequestSubscriptionPropsByPlatforms, } from './types'; +import {decodeAndroidPurchases} from './utils/availablePurchases'; export * from './types'; export * from './vega'; @@ -72,7 +73,8 @@ const normalizeProductType = ( ); }; -const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => purchases; +const normalizePurchaseArray = (purchases: Purchase[]): Purchase[] => + decodeAndroidPurchases(purchases); const getAndroidRequest = ( request?: diff --git a/libraries/expo-iap/src/index.ts b/libraries/expo-iap/src/index.ts index e81db1ddb..920bb7db2 100644 --- a/libraries/expo-iap/src/index.ts +++ b/libraries/expo-iap/src/index.ts @@ -4,12 +4,17 @@ import {Platform} from 'react-native'; // Internal modules import ExpoIapModule, {getNativeModule} from './ExpoIapModule'; import {isVegaOS} from './vega'; -import {isProductIOS, deepLinkToSubscriptionsIOS, syncIOS} from './modules/ios'; +import {isProductIOS, deepLinkToSubscriptionsIOS} from './modules/ios'; import { isProductAndroid, deepLinkToSubscriptionsAndroid, } from './modules/android'; import {ExpoIapConsole} from './utils/debug'; +import {restorePurchasesIOSNative} from './utils/restorePurchases'; +import { + decodeAndroidPurchases, + decodeApplePurchases, +} from './utils/availablePurchases'; // Types import type { @@ -73,7 +78,9 @@ type ExpoIapEventPayloads = { [OpenIapEvent.PurchaseUpdated]: Purchase; [OpenIapEvent.PurchaseError]: PurchaseError; [OpenIapEvent.PromotedProductIOS]: - Product | string | {id?: string; productId?: string}; + | Product + | string + | {id?: string; productId?: string}; [OpenIapEvent.UserChoiceBillingAndroid]: UserChoiceBillingDetails; [OpenIapEvent.DeveloperProvidedBillingAndroid]: DeveloperProvidedBillingDetailsAndroid; [OpenIapEvent.SubscriptionBillingIssue]: Purchase; @@ -379,7 +386,8 @@ export const promotedProductListenerIOS = ( let pendingProduct: Promise | undefined; try { pendingProduct = ExpoIapModule.getPromotedProductIOS() as - Promise | undefined; + | Promise + | undefined; } catch { return Promise.resolve(); } @@ -594,8 +602,8 @@ const invokeNativeWithPurchaseError = async ( typeof nativeError?.message === 'string' ? nativeError.message : typeof error === 'string' - ? error - : ''; + ? error + : ''; const hasCanonicalFields = nativeMessage.includes(OPENIAP_ERROR_ENVELOPE_PREFIX) || nativeError?.code !== undefined || @@ -752,7 +760,7 @@ export const getAvailablePurchases: QueryField< if (isVegaOS()) { const purchases = await ExpoIapModule.getAvailableItems(normalizedOptions); - return purchases as Purchase[]; + return decodeAndroidPurchases(purchases); } let purchases: Purchase[]; @@ -770,7 +778,9 @@ export const getAvailablePurchases: QueryField< throw unsupportedPlatformError(); } - return purchases as Purchase[]; + return Platform.OS === 'ios' + ? decodeApplePurchases(purchases) + : decodeAndroidPurchases(purchases); }; /** @@ -909,7 +919,8 @@ function normalizeRequestProps( ): RequestSubscriptionAndroidProps | null | undefined; function normalizeRequestProps( request: - RequestPurchasePropsByPlatforms | RequestSubscriptionPropsByPlatforms, + | RequestPurchasePropsByPlatforms + | RequestSubscriptionPropsByPlatforms, platform: 'ios' | 'android', ) { if (platform === 'ios') { @@ -1200,16 +1211,7 @@ export const finishTransaction: MutationField<'finishTransaction'> = async ({ */ export const restorePurchases: MutationField<'restorePurchases'> = async () => { if (Platform.OS === 'ios') { - const nativeModule = ExpoIapModule as any; - - if ( - nativeModule.USING_ONSIDE_SDK && - typeof nativeModule.restorePurchases === 'function' - ) { - await nativeModule.restorePurchases().catch(() => undefined); - } else { - await syncIOS().catch(() => undefined); - } + await restorePurchasesIOSNative(); } await getAvailablePurchases({ @@ -1340,8 +1342,9 @@ export const verifyPurchaseWithProvider: MutationField< } } - const result = - await ExpoIapModule.verifyPurchaseWithProvider(resolvedOptions); + const result = await ExpoIapModule.verifyPurchaseWithProvider( + resolvedOptions, + ); if (result.iapkit == null) { return result; } diff --git a/libraries/expo-iap/src/modules/__tests__/ios.test.ts b/libraries/expo-iap/src/modules/__tests__/ios.test.ts index bf38d4f4b..365117b9c 100644 --- a/libraries/expo-iap/src/modules/__tests__/ios.test.ts +++ b/libraries/expo-iap/src/modules/__tests__/ios.test.ts @@ -18,6 +18,7 @@ jest.mock('../../ExpoIapModule', () => ({ getAppTransactionIOS: jest.fn(), getPromotedProductIOS: jest.fn(), getPendingTransactionsIOS: jest.fn(), + getAllTransactionsIOS: jest.fn(), clearTransactionIOS: jest.fn(), canPresentExternalPurchaseNoticeIOS: jest.fn(), presentExternalPurchaseNoticeSheetIOS: jest.fn(), @@ -64,6 +65,7 @@ import { deepLinkToSubscriptionsIOS, isProductIOS, getPendingTransactionsIOS, + getAllTransactionsIOS, clearTransactionIOS, canPresentExternalPurchaseNoticeIOS, presentExternalPurchaseNoticeSheetIOS, @@ -74,6 +76,17 @@ import { } from '../ios'; /* eslint-enable import/first */ +const validPurchase = (id: string) => ({ + id, + transactionId: id, + productId: 'premium', + transactionDate: 1720000000000, + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, +}); + describe('iOS Module Functions', () => { beforeEach(() => { jest.clearAllMocks(); @@ -372,9 +385,7 @@ describe('iOS Module Functions', () => { }); it('should call showManageSubscriptionsIOS', async () => { - const mockPurchases: any[] = [ - {id: 'legacy', transactionId: 'txn-77', platform: 'ios'}, - ]; + const mockPurchases: any[] = [validPurchase('txn-77')]; (ExpoIapModule.showManageSubscriptionsIOS as jest.Mock).mockResolvedValue( mockPurchases, ); @@ -383,18 +394,18 @@ describe('iOS Module Functions', () => { expect(ExpoIapModule.showManageSubscriptionsIOS).toHaveBeenCalledTimes(1); expect(Array.isArray(result)).toBe(true); - expect(result[0]?.id).toBe('legacy'); + expect(result[0]?.id).toBe('txn-77'); expect(result[0]?.transactionId).toBe('txn-77'); }); - it('showManageSubscriptionsIOS returns empty array when native returns null', async () => { + it('showManageSubscriptionsIOS rejects when native returns null', async () => { (ExpoIapModule.showManageSubscriptionsIOS as jest.Mock).mockResolvedValue( null, ); - const result = await showManageSubscriptionsIOS(); - - expect(result).toEqual([]); + await expect(showManageSubscriptionsIOS()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); }); it('should call getReceiptDataIOS', async () => { @@ -526,13 +537,13 @@ describe('iOS Module Functions', () => { it('normalizes pending transactions list', async () => { (ExpoIapModule.getPendingTransactionsIOS as jest.Mock).mockResolvedValue([ - {id: 'legacy-id', transactionId: 'txn-pending', platform: 'ios'}, + validPurchase('txn-pending'), ]); const result = await getPendingTransactionsIOS(); expect(ExpoIapModule.getPendingTransactionsIOS).toHaveBeenCalledTimes(1); - expect(result[0].id).toBe('legacy-id'); + expect(result[0].id).toBe('txn-pending'); }); it('clears iOS transactions', async () => { @@ -544,14 +555,22 @@ describe('iOS Module Functions', () => { expect(result).toBe(true); }); - it('getPendingTransactionsIOS returns empty list when native returns null', async () => { + it('transaction list APIs reject null and mixed foreign batches', async () => { (ExpoIapModule.getPendingTransactionsIOS as jest.Mock).mockResolvedValue( null, ); + await expect(getPendingTransactionsIOS()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); - const result = await getPendingTransactionsIOS(); - - expect(result).toEqual([]); + const valid = validPurchase('valid'); + (ExpoIapModule.getAllTransactionsIOS as jest.Mock).mockResolvedValue([ + valid, + {...valid, id: 'foreign', store: 'google'}, + ]); + await expect(getAllTransactionsIOS()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); }); it('clearTransactionIOS returns false when native resolves undefined', async () => { @@ -734,8 +753,9 @@ describe('iOS Module Functions', () => { ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS as jest.Mock ).mockResolvedValue(mockResult); - const result = - await getExternalPurchaseCustomLinkTokenIOS('acquisition'); + const result = await getExternalPurchaseCustomLinkTokenIOS( + 'acquisition', + ); expect( ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS, @@ -765,8 +785,9 @@ describe('iOS Module Functions', () => { ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS as jest.Mock ).mockResolvedValue(mockResult); - const result = - await getExternalPurchaseCustomLinkTokenIOS('acquisition'); + const result = await getExternalPurchaseCustomLinkTokenIOS( + 'acquisition', + ); expect(result.error).toBe('App not eligible'); expect(result.token).toBeUndefined(); diff --git a/libraries/expo-iap/src/modules/ios.ts b/libraries/expo-iap/src/modules/ios.ts index 9e3a0ecf0..b57228e4c 100644 --- a/libraries/expo-iap/src/modules/ios.ts +++ b/libraries/expo-iap/src/modules/ios.ts @@ -18,6 +18,7 @@ import type { SubscriptionStatusIOS, } from '../types'; import {type PurchaseError} from '../utils/errorMapping'; +import {decodeApplePurchases} from '../utils/availablePurchases'; import {Linking, Platform} from 'react-native'; /** @@ -194,7 +195,7 @@ export const showManageSubscriptionsIOS: MutationField< > = async () => { requireIosPlatform('showManageSubscriptionsIOS'); const purchases = await ExpoIapModule.showManageSubscriptionsIOS(); - return (purchases ?? []) as PurchaseIOS[]; + return decodeApplePurchases(purchases); }; /** @@ -352,7 +353,7 @@ export const getPendingTransactionsIOS: QueryField< > = async () => { requireIosPlatform('getPendingTransactionsIOS'); const transactions = await ExpoIapModule.getPendingTransactionsIOS(); - return (transactions ?? []) as PurchaseIOS[]; + return decodeApplePurchases(transactions); }; /** @@ -365,7 +366,7 @@ export const getAllTransactionsIOS: QueryField< > = async () => { requireIosPlatform('getAllTransactionsIOS'); const transactions = await ExpoIapModule.getAllTransactionsIOS(); - return (transactions ?? []) as PurchaseIOS[]; + return decodeApplePurchases(transactions); }; /** @@ -483,8 +484,9 @@ export const getExternalPurchaseCustomLinkTokenIOS: QueryField< "getExternalPurchaseCustomLinkTokenIOS requires a tokenType ('acquisition' or 'services')", ); } - const result = - await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS(tokenType); + const result = await ExpoIapModule.getExternalPurchaseCustomLinkTokenIOS( + tokenType, + ); return result as ExternalPurchaseCustomLinkTokenResultIOS; }; @@ -509,8 +511,9 @@ export const showExternalPurchaseCustomLinkNoticeIOS: MutationField< "showExternalPurchaseCustomLinkNoticeIOS requires a noticeType ('browser')", ); } - const result = - await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS(noticeType); + const result = await ExpoIapModule.showExternalPurchaseCustomLinkNoticeIOS( + noticeType, + ); return result as ExternalPurchaseCustomLinkNoticeResultIOS; }; diff --git a/libraries/expo-iap/src/useIAP.ts b/libraries/expo-iap/src/useIAP.ts index 9141c2345..3f4ab80fc 100644 --- a/libraries/expo-iap/src/useIAP.ts +++ b/libraries/expo-iap/src/useIAP.ts @@ -25,10 +25,8 @@ import { type ProductTypeInput, } from './index'; import {ExpoIapConsole} from './utils/debug'; -import { - getPromotedProductIOS, - syncIOS, -} from './modules/ios'; +import {getPromotedProductIOS} from './modules/ios'; +import {restorePurchasesIOSNative} from './utils/restorePurchases'; import { getBillingChoiceInfoAndroid, isBillingProgramAvailableAndroid, @@ -459,10 +457,11 @@ export function useIAP(options?: UseIAPOptions): UseIap { return await hasActiveSubscriptions(subscriptionIds); } catch (error) { ExpoIapConsole.error('Error checking active subscriptions:', error); - return false; + invokeOnError(error); + throw error; } }, - [], + [invokeOnError], ); /** @@ -556,8 +555,8 @@ export function useIAP(options?: UseIAPOptions): UseIap { const purchases = Array.isArray(purchaseResult) ? purchaseResult : purchaseResult - ? [purchaseResult] - : []; + ? [purchaseResult] + : []; for (const purchase of purchases ?? []) { if (!markPurchaseDelivered(purchase)) { @@ -585,9 +584,8 @@ export function useIAP(options?: UseIAPOptions): UseIap { const restorePurchasesInternal = useCallback( async (options?: PurchaseOptions): Promise => { try { - // iOS: Try to sync first, but don't fail if sync errors occur if (Platform.OS === 'ios') { - await syncIOS().catch(() => undefined); // syncIOS returns Promise, we don't need the result + await restorePurchasesIOSNative(); } const purchases = await getAvailablePurchases({ @@ -629,7 +627,8 @@ export function useIAP(options?: UseIAPOptions): UseIap { // Build the canonical billing-program connection config. const buildConnectionConfig = useCallback((): - InitConnectionConfig | undefined => { + | InitConnectionConfig + | undefined => { if (optionsRef.current?.enableBillingProgramAndroid) { return { enableBillingProgramAndroid: diff --git a/libraries/expo-iap/src/utils/availablePurchases.ts b/libraries/expo-iap/src/utils/availablePurchases.ts new file mode 100644 index 000000000..e68d946cd --- /dev/null +++ b/libraries/expo-iap/src/utils/availablePurchases.ts @@ -0,0 +1,105 @@ +import {ErrorCode, type Purchase, type PurchaseIOS} from '../types'; +import {createPurchaseError} from './errorMapping'; + +const ANDROID_STORES = new Set(['google', 'amazon', 'horizon']); + +const malformedPurchaseError = (message: string) => + createPurchaseError({ + code: ErrorCode.BillingResponseJsonParseError, + message, + }); + +/** Decode an authoritative native purchase list without partial success. */ +export const decodeAvailablePurchases = (value: unknown): Purchase[] => { + if (!Array.isArray(value)) { + throw malformedPurchaseError( + 'Native bridge returned a malformed purchase list', + ); + } + + value.forEach((item, index) => { + if (item == null || typeof item !== 'object') { + throw malformedPurchaseError( + `Native bridge returned a malformed purchase at index ${index}`, + ); + } + const purchase = item as Partial; + if ( + typeof purchase.id !== 'string' || + purchase.id.length === 0 || + typeof purchase.productId !== 'string' || + purchase.productId.length === 0 || + typeof purchase.transactionDate !== 'number' || + !Number.isFinite(purchase.transactionDate) || + typeof purchase.store !== 'string' || + typeof purchase.quantity !== 'number' || + !Number.isInteger(purchase.quantity) || + typeof purchase.purchaseState !== 'string' || + typeof purchase.isAutoRenewing !== 'boolean' || + (purchase.store === 'apple' && + (typeof purchase.transactionId !== 'string' || + purchase.transactionId.length === 0)) + ) { + throw malformedPurchaseError( + `Native bridge returned a purchase missing required fields at index ${index}`, + ); + } + const candidate = purchase as Record; + if ( + candidate.ids != null && + (!Array.isArray(candidate.ids) || + candidate.ids.some((id) => typeof id !== 'string')) + ) { + throw malformedPurchaseError( + `Native bridge returned malformed purchase ids at index ${index}`, + ); + } + for (const field of [ + 'pendingPurchaseUpdateAndroid', + 'offerIOS', + 'renewalInfoIOS', + 'commitmentInfoIOS', + 'advancedCommerceInfoIOS', + ]) { + const nested = candidate[field]; + if ( + nested != null && + (typeof nested !== 'object' || Array.isArray(nested)) + ) { + throw malformedPurchaseError( + `Native bridge returned a malformed ${field} at index ${index}`, + ); + } + } + }); + + return value as Purchase[]; +}; + +/** Decode an authoritative StoreKit list without filtering foreign entries. */ +export const decodeApplePurchases = (value: unknown): PurchaseIOS[] => { + const decoded = decodeAvailablePurchases(value); + const invalidIndex = decoded.findIndex( + (purchase) => purchase.store !== 'apple', + ); + if (invalidIndex !== -1) { + throw malformedPurchaseError( + `Native StoreKit bridge returned a non-Apple purchase at index ${invalidIndex}`, + ); + } + return decoded as PurchaseIOS[]; +}; + +/** Decode an authoritative Android-family list without foreign stores. */ +export const decodeAndroidPurchases = (value: unknown): Purchase[] => { + const decoded = decodeAvailablePurchases(value); + const invalidIndex = decoded.findIndex( + (purchase) => !ANDROID_STORES.has(purchase.store), + ); + if (invalidIndex !== -1) { + throw malformedPurchaseError( + `Native Android bridge returned a foreign purchase at index ${invalidIndex}`, + ); + } + return decoded; +}; diff --git a/libraries/expo-iap/src/utils/restorePurchases.ts b/libraries/expo-iap/src/utils/restorePurchases.ts new file mode 100644 index 000000000..22b826187 --- /dev/null +++ b/libraries/expo-iap/src/utils/restorePurchases.ts @@ -0,0 +1,31 @@ +import ExpoIapModule from '../ExpoIapModule'; +import {syncIOS} from '../modules/ios'; +import {ErrorCode} from '../types'; +import {createPurchaseError} from './errorMapping'; + +/** + * Run the native iOS restore/sync phase without querying purchases. + * + * Both native implementations promise an authoritative boolean. A rejection + * or false result must reach the caller so restore cannot report success from + * a subsequent empty purchase query. + */ +export const restorePurchasesIOSNative = async (): Promise => { + const nativeModule = ExpoIapModule as any; + const usingOnside = + nativeModule.USING_ONSIDE_SDK && + typeof nativeModule.restorePurchases === 'function'; + const restored = usingOnside + ? await nativeModule.restorePurchases() + : await syncIOS(); + + if (restored !== true) { + throw createPurchaseError({ + code: ErrorCode.SyncError, + message: usingOnside + ? 'Onside purchase restore did not complete' + : 'App Store purchase sync did not complete', + platform: 'ios', + }); + } +}; diff --git a/libraries/expo-iap/src/vega-adapter.ts b/libraries/expo-iap/src/vega-adapter.ts index 26b7c01a1..81e10bc92 100644 --- a/libraries/expo-iap/src/vega-adapter.ts +++ b/libraries/expo-iap/src/vega-adapter.ts @@ -617,6 +617,14 @@ function isVegaParserError(error: unknown): boolean { ); } +function malformedVegaResponse(error: unknown, operation: string): Error { + const detail = error instanceof Error ? error.message : String(error); + return createVegaError( + ErrorCode.BillingResponseJsonParseError, + `${operation} returned a malformed response: ${detail}`, + ); +} + function createPricingPhase(product: VegaProduct) { return { billingCycleCount: 0, @@ -861,7 +869,7 @@ export function createExpoIapVegaModule( response = await service.getPurchaseUpdates({reset}); } catch (error) { if (isVegaParserError(error)) { - return receipts; + throw malformedVegaResponse(error, 'Amazon Vega purchase updates'); } throw error; } @@ -940,7 +948,10 @@ export function createExpoIapVegaModule( ); } catch (error) { if (isVegaParserError(error)) { - return; + throw malformedVegaResponse( + error, + 'Amazon Vega purchase product metadata', + ); } throw error; } diff --git a/libraries/flutter_inapp_purchase/analysis_options.yaml b/libraries/flutter_inapp_purchase/analysis_options.yaml index 26fd229e8..c7b618b4b 100644 --- a/libraries/flutter_inapp_purchase/analysis_options.yaml +++ b/libraries/flutter_inapp_purchase/analysis_options.yaml @@ -33,6 +33,10 @@ linter: analyzer: exclude: + # Flutter 3.44 resolves Swift Packages into build/ios/SourcePackages. + # Without this guard, `flutter analyze` recursively analyzes a second + # checkout of this monorepo and reports false cross-checkout type errors. + - build/** - lib/types.dart - example/** diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift index f4ff99768..89425b88b 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift @@ -66,6 +66,34 @@ enum FlutterIapHelper { return String(data: data, encoding: .utf8) } + // Keep the framework compatible with the currently published OpenIAP + // native package while still treating serialization as all-or-nothing. + // OpenIapSerialization.encode/purchase return an empty dictionary when + // encoding fails, so reject that sentinel instead of reporting success. + static func encodeRequired(_ value: T) throws -> [String: Any] { + let encoded = OpenIapSerialization.encode(value) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + return encoded + } + + static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] { + try purchases.map { purchase in + let encoded = OpenIapSerialization.purchase(purchase) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native purchase payload" + ) + } + return encoded + } + } + // MARK: - Parsing helpers static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType { diff --git a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index 900f7c17c..b4c7bcfb8 100644 --- a/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -563,7 +563,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { onlyIncludeActive: onlyIncludeActiveItems ) let purchases = try await OpenIapModule.shared.getAvailablePurchases(opts) - let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases)) FlutterIapLog.result("getAvailableItems", value: serialized) await MainActor.run { result(serialized) } } catch let purchaseError as PurchaseError { @@ -587,7 +587,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { Task { @MainActor in do { let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds) - let serialized = subscriptions.map { OpenIapSerialization.encode($0) } + let serialized = try subscriptions.map { try FlutterIapHelper.encodeRequired($0) } let sanitized = FlutterIapHelper.sanitizeArray(serialized) FlutterIapLog.result("getActiveSubscriptions", value: sanitized) await MainActor.run { result(sanitized) } @@ -745,7 +745,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { Task { @MainActor in do { let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS() - let serialized = purchases.map { OpenIapSerialization.encode($0) } + let serialized = try purchases.map { try FlutterIapHelper.encodeRequired($0) } let sanitized = FlutterIapHelper.sanitizeArray(serialized) FlutterIapLog.result("showManageSubscriptionsIOS", value: sanitized) result(sanitized) @@ -827,7 +827,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { do { let pending = try await OpenIapModule.shared.getPendingTransactionsIOS() let purchases = pending.map { Purchase.purchaseIos($0) } - let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases)) FlutterIapLog.result("getPendingTransactionsIOS", value: serialized) result(serialized) } catch let purchaseError as PurchaseError { @@ -847,7 +847,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { do { let all = try await OpenIapModule.shared.getAllTransactionsIOS() let purchases = all.map { Purchase.purchaseIos($0) } - let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases)) FlutterIapLog.result("getAllTransactionsIOS", value: serialized) result(serialized) } catch let purchaseError as PurchaseError { diff --git a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart index c58dd565b..5db595a55 100644 --- a/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart +++ b/libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart @@ -811,6 +811,7 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { platformIsIOS: true, acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens, + rejectMalformed: true, ); } else if (_platform.isAndroid) { final args = { @@ -827,13 +828,19 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { platformIsIOS: false, acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens, + rejectMalformed: true, ); } - return raw - .where((purchase) => purchase.productId.isNotEmpty) - .where(hasResolvableIdentifier) - .toList(growable: false); + for (final purchase in raw) { + if (purchase.productId.isEmpty || + !hasResolvableIdentifier(purchase)) { + throw const FormatException( + 'Native bridge returned a purchase without a required identity', + ); + } + } + return raw; } return await resolvePurchases(); @@ -842,6 +849,11 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { error, 'get available purchases', ); + } on FormatException catch (error) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Failed to decode available purchases: ${error.message}', + ); } catch (error) { if (error is PurchaseError) rethrow; throw PurchaseError( @@ -1202,15 +1214,26 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { platformIsIOS: true, acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens, + rejectMalformed: true, ); - return purchases.whereType().toList( - growable: false, - ); + if (purchases + .any((purchase) => purchase is! gentype.PurchaseIOS)) { + throw const FormatException( + 'Native bridge returned a non-iOS purchase', + ); + } + return purchases.cast(); } on PlatformException catch (error) { throw _purchaseErrorFromPlatformException( error, 'show manage subscriptions', ); + } on FormatException catch (error) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: + 'Failed to decode changed subscriptions: ${error.message}', + ); } catch (error) { if (error is PurchaseError) rethrow; throw PurchaseError( @@ -1239,15 +1262,25 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { platformIsIOS: _platform.isIOS || _platform.isMacOS, acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens, + rejectMalformed: true, ); - return purchases.whereType().toList( - growable: false, - ); + if (purchases.any((purchase) => purchase is! gentype.PurchaseIOS)) { + throw const FormatException( + 'Native bridge returned a non-iOS purchase', + ); + } + return purchases.cast(); } on PlatformException catch (error) { throw _purchaseErrorFromPlatformException( error, 'fetch pending transactions', ); + } on FormatException catch (error) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: + 'Failed to decode pending transactions: ${error.message}', + ); } catch (error) { if (error is PurchaseError) rethrow; throw PurchaseError( @@ -1275,15 +1308,24 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { platformIsIOS: _platform.isIOS || _platform.isMacOS, acknowledgedAndroidPurchaseTokens: _acknowledgedAndroidPurchaseTokens, + rejectMalformed: true, ); - return purchases.whereType().toList( - growable: false, - ); + if (purchases.any((purchase) => purchase is! gentype.PurchaseIOS)) { + throw const FormatException( + 'Native bridge returned a non-iOS purchase', + ); + } + return purchases.cast(); } on PlatformException catch (error) { throw _purchaseErrorFromPlatformException( error, 'fetch all transactions', ); + } on FormatException catch (error) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Failed to decode all transactions: ${error.message}', + ); } catch (error) { if (error is PurchaseError) rethrow; throw PurchaseError( @@ -2298,43 +2340,73 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { List _parseActiveSubscriptions(dynamic result) { List list; if (result is String) { - list = json.decode(result) as List; + final dynamic decoded; + try { + decoded = json.decode(result); + } on FormatException catch (error) { + // Malformed JSON and a non-list payload are the same class of failure, + // so report one code instead of letting FormatException fall through + // to the generic catch and surface as ServiceError. + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Failed to decode native active-subscription response: ' + '${error.message}', + ); + } + if (decoded is! List) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Native active-subscription response was not a list', + ); + } + list = decoded; } else if (result is List) { list = result; } else if (result is Map) { // Some platforms/libs may return a single map; normalize to list list = [result]; } else { - try { - list = json.decode(result.toString()) as List; - } catch (_) { - debugPrint( - '[flutter_inapp_purchase] Unexpected getActiveSubscriptions result type: ${result.runtimeType}', - ); - list = const []; - } + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Unexpected active-subscription response type: ' + '${result.runtimeType}', + ); } - final subscriptions = []; - for (final dynamic item in list) { + return list.map((dynamic item) { + if (item is! Map) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: + 'Native active-subscription response contained a non-map item', + ); + } + final map = _deepConvertMap(item) as Map; + final transactionDate = map['transactionDate']; + final renewalInfo = map['renewalInfoIOS']; + if (map['productId'] is! String || + (map['productId'] as String).isEmpty || + map['transactionId'] is! String || + (map['transactionId'] as String).isEmpty || + map['isActive'] is! bool || + transactionDate is! num || + !transactionDate.toDouble().isFinite || + renewalInfo != null && renewalInfo is! Map) { + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: + 'Native active-subscription response contained malformed fields', + ); + } try { - if (item is! Map) { - debugPrint( - '[flutter_inapp_purchase] Skipping subscription with unexpected type: ${item.runtimeType}', - ); - continue; - } - // Recursively convert map to Map - final map = _deepConvertMap(item) as Map; - subscriptions.add(gentype.ActiveSubscription.fromJson(map)); + return gentype.ActiveSubscription.fromJson(map); } catch (error) { - debugPrint( - '[flutter_inapp_purchase] Skipping subscription due to parse error: $error', + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Failed to decode native active-subscription response', ); } - } - - return subscriptions; + }).toList(); } /// Get details of all currently active subscriptions. @@ -2360,7 +2432,10 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { ); if (result == null) { - return []; + throw PurchaseError( + code: gentype.ErrorCode.BillingResponseJsonParseError, + message: 'Native active-subscription response was null', + ); } return _parseActiveSubscriptions(result); @@ -2384,27 +2459,21 @@ class FlutterInappPurchase with RequestPurchaseBuilderApi { /// See: https://openiap.dev/docs/apis/has-active-subscriptions gentype.QueryHasActiveSubscriptionsHandler get hasActiveSubscriptions => ([subscriptionIds]) async { - try { - final activeSubscriptions = await getActiveSubscriptions( - subscriptionIds, - ); - // For Android, also call native with explicit type for parity/logging - if (_platform.isAndroid) { - try { - await _channel.invokeMethod( - 'getAvailableItems', - {'type': 'subs'}, - ); - } catch (_) { - // Ignore; this is for logging/compatibility only - } + final activeSubscriptions = await getActiveSubscriptions( + subscriptionIds, + ); + // For Android, also call native with explicit type for parity/logging + if (_platform.isAndroid) { + try { + await _channel.invokeMethod( + 'getAvailableItems', + {'type': 'subs'}, + ); + } catch (_) { + // Ignore; this is for logging/compatibility only } - return activeSubscriptions.isNotEmpty; - } catch (error) { - // If there's an error getting subscriptions, return false - debugPrint('Error checking active subscriptions: $error'); - return false; } + return activeSubscriptions.isNotEmpty; }; // MARK: - Billing Programs API (Android 8.2.0+) diff --git a/libraries/flutter_inapp_purchase/lib/helpers.dart b/libraries/flutter_inapp_purchase/lib/helpers.dart index 3465b5b24..b0e9ea450 100644 --- a/libraries/flutter_inapp_purchase/lib/helpers.dart +++ b/libraries/flutter_inapp_purchase/lib/helpers.dart @@ -232,7 +232,7 @@ gentype.Purchase convertToPurchase( if (purchaseId == null || purchaseId.isEmpty) { debugPrint( - '[flutter_inapp_purchase] Skipping purchase with missing identifiers', + '[flutter_inapp_purchase] Invalid purchase payload: missing identifier', ); throw const FormatException('Missing purchase identifier'); } @@ -354,20 +354,36 @@ List extractPurchases( required bool platformIsAndroid, required bool platformIsIOS, required Map acknowledgedAndroidPurchaseTokens, + bool rejectMalformed = false, }) { List list; - if (result is String) { - list = json.decode(result) as List; - } else if (result is List) { - list = result; - } else { - list = json.decode(result.toString()) as List; + try { + if (result is String) { + list = json.decode(result) as List; + } else if (result is List) { + list = result; + } else { + list = json.decode(result.toString()) as List; + } + } catch (_) { + if (rejectMalformed) { + throw const FormatException( + 'Native bridge returned a malformed purchase list', + ); + } + return const []; } final purchases = []; - for (final dynamic product in list) { + for (var index = 0; index < list.length; index += 1) { + final dynamic product = list[index]; try { if (product is! Map) { + if (rejectMalformed) { + throw FormatException( + 'Native bridge returned a malformed purchase at index $index', + ); + } debugPrint( '[flutter_inapp_purchase] Skipping purchase with unexpected type: ${product.runtimeType}', ); @@ -377,12 +393,27 @@ List extractPurchases( // return maps with non-string keys (e.g., Map) final map = normalizeDynamicMap(product); if (map == null) { + if (rejectMalformed) { + throw FormatException( + 'Native bridge returned a malformed purchase at index $index', + ); + } debugPrint( '[flutter_inapp_purchase] Skipping purchase: failed to normalize map', ); continue; } final original = map; // Use normalized data to access additional fields + if (rejectMalformed && + !_isValidAuthoritativePurchaseMap( + map, + platformIsAndroid: platformIsAndroid, + platformIsIOS: platformIsIOS, + )) { + throw FormatException( + 'Native bridge returned a purchase with invalid fields at index $index', + ); + } purchases.add( convertToPurchase( map, @@ -393,6 +424,12 @@ List extractPurchases( ), ); } catch (error) { + if (rejectMalformed) { + if (error is FormatException) rethrow; + throw FormatException( + 'Failed to decode native purchase at index $index', + ); + } debugPrint( '[flutter_inapp_purchase] Skipping purchase due to parse error: $error', ); @@ -402,6 +439,49 @@ List extractPurchases( return purchases; } +bool _isValidAuthoritativePurchaseMap( + Map value, { + required bool platformIsAndroid, + required bool platformIsIOS, +}) { + for (final field in ['id', 'productId', 'store', 'purchaseState']) { + final item = value[field]; + if (item is! String || item.isEmpty) return false; + } + final transactionDate = value['transactionDate']; + if (transactionDate is! num || !transactionDate.isFinite) return false; + final store = value['store']; + if (platformIsIOS && store != 'apple') return false; + if (platformIsAndroid && + store != 'google' && + store != 'amazon' && + store != 'horizon') { + return false; + } + final quantity = value['quantity']; + if (quantity is! num || !quantity.isFinite || quantity % 1 != 0) return false; + if (value['isAutoRenewing'] is! bool) return false; + if (platformIsIOS) { + final transactionId = value['transactionId']; + if (transactionId is! String || transactionId.isEmpty) return false; + } + final ids = value['ids']; + if (ids != null && (ids is! List || ids.any((dynamic id) => id is! String))) { + return false; + } + for (final field in [ + 'pendingPurchaseUpdateAndroid', + 'offerIOS', + 'renewalInfoIOS', + 'commitmentInfoIOS', + 'advancedCommerceInfoIOS', + ]) { + final nested = value[field]; + if (nested != null && nested is! Map) return false; + } + return true; +} + // Private helper functions -------------------------------------------------- /// Safe double parsing that handles both num and String inputs. diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift index f4ff99768..89425b88b 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift @@ -66,6 +66,34 @@ enum FlutterIapHelper { return String(data: data, encoding: .utf8) } + // Keep the framework compatible with the currently published OpenIAP + // native package while still treating serialization as all-or-nothing. + // OpenIapSerialization.encode/purchase return an empty dictionary when + // encoding fails, so reject that sentinel instead of reporting success. + static func encodeRequired(_ value: T) throws -> [String: Any] { + let encoded = OpenIapSerialization.encode(value) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + return encoded + } + + static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] { + try purchases.map { purchase in + let encoded = OpenIapSerialization.purchase(purchase) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native purchase payload" + ) + } + return encoded + } + } + // MARK: - Parsing helpers static func parseProductQueryType(_ rawValue: String?) throws -> ProductQueryType { diff --git a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift index e9ef660dc..d6c321847 100644 --- a/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift +++ b/libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift @@ -517,7 +517,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { onlyIncludeActive: onlyIncludeActiveItems ) let purchases = try await OpenIapModule.shared.getAvailablePurchases(opts) - let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases)) FlutterIapLog.result("getAvailableItems", value: serialized) await MainActor.run { result(serialized) } } catch let purchaseError as PurchaseError { @@ -541,7 +541,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { Task { @MainActor in do { let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds) - let serialized = subscriptions.map { OpenIapSerialization.encode($0) } + let serialized = try subscriptions.map { try FlutterIapHelper.encodeRequired($0) } let sanitized = FlutterIapHelper.sanitizeArray(serialized) FlutterIapLog.result("getActiveSubscriptions", value: sanitized) await MainActor.run { result(sanitized) } @@ -681,7 +681,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { Task { @MainActor in do { let purchases = try await OpenIapModule.shared.showManageSubscriptionsIOS() - let serialized = purchases.map { OpenIapSerialization.encode($0) } + let serialized = try purchases.map { try FlutterIapHelper.encodeRequired($0) } let sanitized = FlutterIapHelper.sanitizeArray(serialized) FlutterIapLog.result("showManageSubscriptionsIOS", value: sanitized) result(sanitized) @@ -763,7 +763,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { do { let pending = try await OpenIapModule.shared.getPendingTransactionsIOS() let purchases = pending.map { Purchase.purchaseIos($0) } - let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases)) FlutterIapLog.result("getPendingTransactionsIOS", value: serialized) result(serialized) } catch let purchaseError as PurchaseError { @@ -783,7 +783,7 @@ public class FlutterInappPurchasePlugin: NSObject, FlutterPlugin { do { let all = try await OpenIapModule.shared.getAllTransactionsIOS() let purchases = all.map { Purchase.purchaseIos($0) } - let serialized = FlutterIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let serialized = FlutterIapHelper.sanitizeArray(try FlutterIapHelper.purchasesRequired(purchases)) FlutterIapLog.result("getAllTransactionsIOS", value: serialized) result(serialized) } catch let purchaseError as PurchaseError { diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart index 1acfb5e96..203b486c0 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_active_subscriptions_test.dart @@ -355,7 +355,8 @@ void main() { expect(subs.single.productId, 'sub.ios.single'); }); - test('handles unexpected result type gracefully', () async { + test('rejects unexpected result types instead of returning empty', + () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -373,10 +374,51 @@ void main() { ); await iap.initConnection(); - final subs = await iap.getActiveSubscriptions(); + await expectLater( + iap.getActiveSubscriptions(), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.BillingResponseJsonParseError, + ), + ), + ); + }); - // Should return empty list instead of crashing - expect(subs, isEmpty); + test('rejects the full batch when one subscription is malformed', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'getActiveSubscriptions': + return >[ + { + 'productId': 'sub.valid', + 'transactionId': 'txn-valid', + 'isActive': true, + 'transactionDate': 1700000000000, + }, + {'productId': 'sub.invalid'}, + ]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'ios'), + ); + await iap.initConnection(); + + await expectLater( + iap.getActiveSubscriptions(), + throwsA(isA()), + ); + await expectLater( + iap.hasActiveSubscriptions(), + throwsA(isA()), + ); }); }); } diff --git a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart index 9d1fd09df..2ab8115fe 100644 --- a/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart +++ b/libraries/flutter_inapp_purchase/test/flutter_inapp_purchase_channel_test.dart @@ -1364,7 +1364,7 @@ void main() { group('getAvailablePurchases', () { test( - 'forwards iOS options to native channel and filters invalid entries', + 'forwards iOS options and rejects a partially malformed batch', () async { final capturedArguments = []; TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger @@ -1402,9 +1402,18 @@ void main() { await iap.initConnection(); - final purchases = await iap.getAvailablePurchases( - onlyIncludeActiveItemsIOS: false, - alsoPublishToEventListenerIOS: true, + await expectLater( + () => iap.getAvailablePurchases( + onlyIncludeActiveItemsIOS: false, + alsoPublishToEventListenerIOS: true, + ), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.BillingResponseJsonParseError, + ), + ), ); final args = Map.from( @@ -1413,11 +1422,122 @@ void main() { expect(args['onlyIncludeActiveItemsIOS'], isFalse); expect(args['alsoPublishToEventListenerIOS'], isTrue); - expect(purchases, hasLength(1)); - expect(purchases.single.productId, 'iap.premium'); }, ); + test('preserves an explicit empty purchase list', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'initConnection') { + return true; + } + if (call.method == 'getAvailableItems') { + return >[]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: 'android'), + ); + await iap.initConnection(); + + await expectLater(iap.getAvailablePurchases(), completion(isEmpty)); + }); + + // The rejection cases above only prove a malformed batch throws. These + // assert the inverse: a complete native payload must still be accepted, so + // a stricter decoder cannot silently break getAvailablePurchases for every + // user of a store that does populate every required field. + for (final testCase in <({String os, String store, String id})>[ + (os: 'ios', store: 'apple', id: 'txn-complete-1'), + (os: 'android', store: 'google', id: 'gpa-complete-1'), + ]) { + test( + 'accepts a complete ${testCase.store} purchase payload', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + switch (call.method) { + case 'initConnection': + return true; + case 'getAvailableItems': + return >[ + { + 'platform': testCase.os, + 'store': testCase.store, + 'id': testCase.id, + 'productId': 'iap.premium', + 'transactionId': testCase.id, + 'purchaseToken': 'token-data', + 'purchaseState': 'PURCHASED', + 'transactionDate': 1700000000000, + 'quantity': 1, + 'isAutoRenewing': false, + }, + ]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: testCase.os), + ); + await iap.initConnection(); + + final purchases = await iap.getAvailablePurchases(); + + expect(purchases, hasLength(1)); + expect(purchases.single.productId, 'iap.premium'); + expect(purchases.single.id, testCase.id); + }, + ); + } + + for (final testCase in <({String os, String foreignStore})>[ + (os: 'ios', foreignStore: 'google'), + (os: 'android', foreignStore: 'apple'), + ]) { + test('rejects a foreign store in the ${testCase.os} purchase list', + () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall call) async { + if (call.method == 'initConnection') return true; + if (call.method == 'getAvailableItems') { + return >[ + { + 'id': 'foreign', + 'productId': 'premium', + 'transactionId': 'foreign', + 'transactionDate': 1700000000000, + 'store': testCase.foreignStore, + 'quantity': 1, + 'purchaseState': 'purchased', + 'isAutoRenewing': false, + }, + ]; + } + return null; + }); + + final iap = FlutterInappPurchase.private( + FakePlatform(operatingSystem: testCase.os), + ); + await iap.initConnection(); + + await expectLater( + () => iap.getAvailablePurchases(), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.BillingResponseJsonParseError, + ), + ), + ); + }); + } + test('throws when connection is not initialized', () async { final iap = FlutterInappPurchase.private( FakePlatform(operatingSystem: 'android'), @@ -1435,7 +1555,7 @@ void main() { ); }); - test('filters Android purchases missing identifiers', () async { + test('rejects Android batches with missing purchase identifiers', () async { TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .setMockMethodCallHandler(channel, (MethodCall call) async { switch (call.method) { @@ -1475,16 +1595,15 @@ void main() { await iap.initConnection(); - final purchases = await iap.getAvailablePurchases(); - expect(purchases, hasLength(1)); - final purchase = purchases.single as types.PurchaseAndroid; - expect(purchase.productId, 'coins.100'); - expect(purchase.dataAndroid, '{"orderId":"order-android"}'); - expect(purchase.currentPlanId, 'base-plan'); - expect(purchase.isSuspendedAndroid, isTrue); - expect( - purchase.pendingPurchaseUpdateAndroid?.purchaseToken, - 'pending-token', + await expectLater( + () => iap.getAvailablePurchases(), + throwsA( + isA().having( + (error) => error.code, + 'code', + types.ErrorCode.BillingResponseJsonParseError, + ), + ), ); }); diff --git a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart index 403989612..797d1461d 100644 --- a/libraries/flutter_inapp_purchase/test/ios_methods_test.dart +++ b/libraries/flutter_inapp_purchase/test/ios_methods_test.dart @@ -166,7 +166,10 @@ void main() { 'productId': 'com.example.prod1', 'transactionDate': DateTime.now().millisecondsSinceEpoch, 'transactionId': '1000001', + 'purchaseState': 'PURCHASED', 'purchaseToken': 'jwt-token', + 'quantity': 1, + 'isAutoRenewing': false, 'platform': 'ios', 'store': 'apple', }, @@ -285,6 +288,47 @@ void main() { ); }); + test('authoritative iOS lists reject mixed malformed payloads', () async { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (MethodCall methodCall) async { + if (methodCall.method == 'showManageSubscriptionsIOS' || + methodCall.method == 'getPendingTransactionsIOS' || + methodCall.method == 'getAllTransactionsIOS') { + return >[ + { + 'id': 'valid', + 'productId': 'premium', + 'transactionDate': 1700000000000, + 'transactionId': 'valid', + 'purchaseState': 'PURCHASED', + 'quantity': 1, + 'isAutoRenewing': false, + 'store': 'apple', + }, + {'id': 'malformed'}, + ]; + } + return null; + }); + + for (final request in > Function()>[ + iap.showManageSubscriptionsIOS, + iap.getPendingTransactionsIOS, + iap.getAllTransactionsIOS, + ]) { + await expectLater( + request(), + throwsA( + isA().having( + (error) => error.code, + 'code', + ErrorCode.BillingResponseJsonParseError, + ), + ), + ); + } + }); + test('isEligibleForIntroOfferIOS returns platform result', () async { expect(await iap.isEligibleForIntroOfferIOS('group'), isTrue); expect(calls.last.method, 'isEligibleForIntroOfferIOS'); diff --git a/libraries/godot-iap/EXAMPLES.md b/libraries/godot-iap/EXAMPLES.md index 31fe33821..bd8bb5510 100644 --- a/libraries/godot-iap/EXAMPLES.md +++ b/libraries/godot-iap/EXAMPLES.md @@ -282,8 +282,16 @@ func _find_subscription(subscription_id: String): ### 3.4 Checking Subscription Status ```gdscript -func check_subscription_status() -> bool: - var purchases = iap.get_available_purchases() +func check_subscription_status() -> Variant: + var available_result = await iap.get_available_purchases_result() + if not available_result.get("success", false): + push_error( + "Subscription query failed: %s (%s)" + % [available_result.get("error"), available_result.get("code")] + ) + # Leave the existing entitlement state unchanged on store failure. + return null + var purchases: Array = available_result.get("purchases", []) for purchase in purchases: var product_id = purchase.product_id if purchase is Object else purchase.get("productId", "") @@ -585,8 +593,16 @@ func restore_purchases(): push_error("IAP not connected") return - # Get all available (unfinished) purchases - var purchases = iap.get_available_purchases() + # Use the structured result so a store failure is not mistaken for an + # authoritative empty purchase list. + var available_result = await iap.get_available_purchases_result() + if not available_result.get("success", false): + push_error( + "Restore query failed: %s (%s)" + % [available_result.get("error"), available_result.get("code")] + ) + return + var purchases: Array = available_result.get("purchases", []) print("Found %d purchases to restore" % purchases.size()) @@ -627,7 +643,14 @@ func restore_with_feedback(): restore_failed.emit("Store not available") return - var purchases = iap.get_available_purchases() + var available_result = await iap.get_available_purchases_result() + if not available_result.get("success", false): + restore_failed.emit( + "%s (%s)" + % [available_result.get("error"), available_result.get("code")] + ) + return + var purchases: Array = available_result.get("purchases", []) if purchases.size() == 0: restore_completed.emit(0) @@ -732,7 +755,14 @@ func _process_fetched_products(fetched: Array): products_loaded.emit(products.values()) func _check_pending_purchases(): - var purchases = iap.get_available_purchases() + var available_result = await iap.get_available_purchases_result() + if not available_result.get("success", false): + push_warning( + "Pending purchase query failed: %s (%s)" + % [available_result.get("error"), available_result.get("code")] + ) + return + var purchases: Array = available_result.get("purchases", []) for purchase in purchases: await _process_purchase(purchase) @@ -817,7 +847,7 @@ func get_price(product_id: String) -> String: return product.display_price if product is Object else product.get("displayPrice", "$0.99") func restore(): - var result = iap.restore_purchases() + var result = await iap.restore_purchases() return result.success if result else false ``` diff --git a/libraries/godot-iap/Example/iap_manager.gd b/libraries/godot-iap/Example/iap_manager.gd index 603cd3340..a4cf991a7 100644 --- a/libraries/godot-iap/Example/iap_manager.gd +++ b/libraries/godot-iap/Example/iap_manager.gd @@ -70,7 +70,17 @@ func _fetch_products_delayed() -> void: ## Clear pending purchases that weren't finished (e.g., app crashed after purchase) func _clear_pending_purchases() -> void: print("[IAPManager] Checking for pending purchases...") - var pending_purchases = await GodotIapPlugin._get_available_purchases_raw() + var available_result = await GodotIapPlugin.get_available_purchases_result() + if not available_result.get("success", false): + push_warning( + "[IAPManager] Could not query pending purchases: %s (%s)" + % [ + available_result.get("error", "Unknown store error"), + available_result.get("code", "unknown"), + ] + ) + return + var pending_purchases = available_result.get("purchases", []) if pending_purchases.size() == 0: print("[IAPManager] No pending purchases found") @@ -444,10 +454,20 @@ func restore_purchases() -> void: purchases_restored.emit() -func is_premium_purchased() -> bool: +func is_premium_purchased() -> Variant: ## Check if premium was purchased (for non-consumables) - ## Returns typed purchase objects (Types.PurchaseAndroid or Types.PurchaseIOS) - var purchases = await GodotIapPlugin.get_available_purchases() + ## Returns null on query failure so callers do not revoke a valid entitlement. + var available_result = await GodotIapPlugin.get_available_purchases_result() + if not available_result.get("success", false): + push_error( + "Premium entitlement query failed: %s (%s)" + % [ + available_result.get("error", "Unknown store error"), + available_result.get("code", "unknown"), + ] + ) + return null + var purchases: Array = available_result.get("purchases", []) for purchase in purchases: # Access typed property directly if purchase.product_id == PRODUCT_PREMIUM: diff --git a/libraries/godot-iap/Example/tests/test_envelope_parsing.gd b/libraries/godot-iap/Example/tests/test_envelope_parsing.gd index de2d17ed6..962480244 100644 --- a/libraries/godot-iap/Example/tests/test_envelope_parsing.gd +++ b/libraries/godot-iap/Example/tests/test_envelope_parsing.gd @@ -44,13 +44,37 @@ class FakeAndroidJsonPlugin: last_args = [] return _respond("getAvailablePurchases", "[]") + func getAvailablePurchasesResult() -> String: + last_args = [] + return _respond( + "getAvailablePurchasesResult", + JSON.stringify({"success": true, "purchases": []}) + ) + + func getAvailablePurchasesResultWithOptions(options_json: String) -> String: + last_args = [options_json] + return _respond( + "getAvailablePurchasesResultWithOptions", + JSON.stringify({"success": true, "purchases": []}) + ) + func getActiveSubscriptions(ids_json) -> String: last_args = [ids_json] return _respond("getActiveSubscriptions", "[]") + func getActiveSubscriptionsResult(ids_json) -> String: + last_args = [ids_json] + return _respond( + "getActiveSubscriptionsResult", + JSON.stringify({"success": true, "subscriptions": []}) + ) + func hasActiveSubscriptions(ids_json) -> String: last_args = [ids_json] - return _respond("hasActiveSubscriptions", JSON.stringify({"hasActive": false})) + return _respond( + "hasActiveSubscriptions", + JSON.stringify({"success": true, "hasActive": false}) + ) func launchExternalLinkAndroid(params_json: String) -> String: last_args = [params_json] @@ -135,6 +159,8 @@ func _run_all_tests() -> void: test_parse_request_id() test_ios_async_result_key() await test_products_fetched_cache() + await test_ios_async_timeout_and_late_callback() + await test_ios_async_disconnect_and_concurrency() test_android_signal_handlers_parse_json() # Android JSON envelopes @@ -144,6 +170,8 @@ func _run_all_tests() -> void: test_android_request_purchase_error_envelope() test_android_request_purchase_empty_response() test_android_request_purchase_unparseable_response() + test_android_request_purchase_unsuccessful_response() + test_android_request_purchase_pending_response() test_request_purchase_unsupported_platform() test_ios_request_purchase_requires_sku() test_android_purchase_uses_canonical_native_wire() @@ -289,6 +317,98 @@ func test_products_fetched_cache() -> void: _assert_equal(GodotIapPlugin._ios_async_results.size(), 0, "Resolved completions should be evicted from the cache") +func test_ios_async_timeout_and_late_callback() -> void: + GodotIapPlugin._ios_async_results.clear() + GodotIapPlugin._ios_async_waiters.clear() + GodotIapPlugin._ios_async_terminal_keys.clear() + GodotIapPlugin._ios_async_terminal_order.clear() + var published: Array[Dictionary] = [] + var capture = func(payload: Dictionary) -> void: + published.append(payload) + GodotIapPlugin.products_fetched.connect(capture) + + var result = await GodotIapPlugin._await_products_fetched_for( + "syncIOS", "timeout-1", 0.01 + ) + _assert_equal(result.get("success"), false, "Timed-out iOS calls should fail") + _assert_equal(result.get("code"), "service-timeout", "Timed-out iOS calls should use service-timeout") + _assert_equal(GodotIapPlugin._ios_async_waiters.size(), 0, "Timed-out waiters should be removed") + + GodotIapPlugin._on_products_fetched({ + "method": "syncIOS", + "requestId": "timeout-1", + "success": true, + }) + _assert_equal(published.size(), 0, "Late completions after timeout should be ignored") + _assert_equal(GodotIapPlugin._ios_async_results.size(), 0, "Late completions should not refill the cache") + GodotIapPlugin.products_fetched.disconnect(capture) + + +func test_ios_async_disconnect_and_concurrency() -> void: + GodotIapPlugin._ios_async_results.clear() + GodotIapPlugin._ios_async_waiters.clear() + GodotIapPlugin._ios_async_terminal_keys.clear() + GodotIapPlugin._ios_async_terminal_order.clear() + + var first_state = GodotIapPlugin._await_products_fetched_for("syncIOS", "first", 1.0) + var second_state = GodotIapPlugin._await_products_fetched_for("syncIOS", "second", 1.0) + await process_frame + GodotIapPlugin._on_products_fetched({ + "method": "syncIOS", + "requestId": "second", + "success": true, + "value": 2, + }) + GodotIapPlugin._on_disconnected() + + var first = await first_state + var second = await second_state + _assert_equal(first.get("code"), "service-disconnected", "Disconnect should cancel every pending request") + _assert_equal(second.get("value"), 2, "Concurrent completions should resolve only their requestId") + _assert_equal(GodotIapPlugin._ios_async_waiters.size(), 0, "Disconnect should leave no pending waiters") + + var tree_exit_state = GodotIapPlugin._await_products_fetched_for( + "getAvailablePurchases", "tree-exit", 1.0 + ) + await process_frame + GodotIapPlugin._exit_tree() + var tree_exit_result = await tree_exit_state + _assert_equal( + tree_exit_result.get("code"), + "service-disconnected", + "Leaving the scene tree should cancel pending iOS requests" + ) + _assert_equal( + GodotIapPlugin._ios_async_waiters.size(), + 0, + "Tree-exit cancellation should leave no pending waiters" + ) + + GodotIapPlugin._ios_async_results.clear() + GodotIapPlugin._ios_async_result_order.clear() + GodotIapPlugin._ios_async_terminal_keys.clear() + GodotIapPlugin._ios_async_terminal_order.clear() + for index in range(GodotIapPlugin.IOS_ASYNC_RESULT_CACHE_LIMIT + 10): + GodotIapPlugin._on_products_fetched({ + "method": "syncIOS", + "requestId": "cached-%d" % index, + "success": true, + }) + _assert_equal( + GodotIapPlugin._ios_async_results.size(), + GodotIapPlugin.IOS_ASYNC_RESULT_CACHE_LIMIT, + "Unclaimed iOS completions should respect the result-cache limit" + ) + var evicted = await GodotIapPlugin._await_products_fetched_for( + "syncIOS", "cached-0", 1.0 + ) + _assert_equal( + evicted.get("code"), + "service-error", + "Evicted completions should fail immediately instead of waiting for timeout" + ) + + func test_android_signal_handlers_parse_json() -> void: var purchases: Array[Dictionary] = [] var errors: Array[Dictionary] = [] @@ -496,6 +616,51 @@ func test_android_request_purchase_unparseable_response() -> void: _uninstall_fake() +func test_android_request_purchase_unsuccessful_response() -> void: + var fake = _install_android_fake() + fake.responses["requestPurchase"] = "{}" + var errors: Array[Dictionary] = [] + var capture_error = func(error: Dictionary) -> void: + errors.append(error) + GodotIapPlugin.purchase_error.connect(capture_error) + + var purchase = GodotIapPlugin.request_purchase(_make_purchase_props("sku.unsuccessful")) + _assert_equal(purchase, null, "Empty success envelopes should not become purchases") + _assert_equal(errors.size(), 1, "Empty success envelopes should emit exactly one purchase_error") + _assert_equal(errors[0].get("code"), "unknown", "Unclassified native failures should use unknown") + + GodotIapPlugin.purchase_error.disconnect(capture_error) + _uninstall_fake() + + +func test_android_request_purchase_pending_response() -> void: + var fake = _install_android_fake() + var errors: Array[Dictionary] = [] + var capture_error = func(error: Dictionary) -> void: + errors.append(error) + GodotIapPlugin.purchase_error.connect(capture_error) + + fake.responses["requestPurchase"] = JSON.stringify({"status": "pending"}) + var result = GodotIapPlugin._request_purchase_raw({ + "type": "in-app", + "requestPurchase": {"google": {"skus": ["sku.pending"]}}, + }) + _assert_equal(result.get("status"), "pending", "Pending dispatch envelopes should be accepted") + _assert_equal(errors.size(), 0, "Pending dispatch should not emit purchase_error") + + fake.responses["requestPurchase"] = JSON.stringify({"success": true, "pending": true}) + var public_result = GodotIapPlugin.request_purchase(_make_purchase_props("sku.pending")) + _assert_equal( + public_result, + null, + "Pending Android dispatch should not become an incomplete PurchaseAndroid" + ) + _assert_equal(errors.size(), 0, "Pending Android dispatch should remain event-driven") + + GodotIapPlugin.purchase_error.disconnect(capture_error) + _uninstall_fake() + + func test_request_purchase_unsupported_platform() -> void: var fake = FakeAndroidJsonPlugin.new() GodotIapPlugin._native_plugin = fake @@ -601,8 +766,9 @@ func test_android_finish_transaction_envelopes() -> void: func test_android_available_purchases_envelope() -> void: var fake = _install_android_fake() - fake.responses["getAvailablePurchases"] = JSON.stringify([ - { + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({ + "success": true, + "purchases": [{ "id": "txn-9", "productId": "owned.sku", "purchaseToken": "token-9", @@ -613,31 +779,80 @@ func test_android_available_purchases_envelope() -> void: "quantity": 1, "isAutoRenewing": false, "transactionDate": 1720000000000.0, - }, - ]) + }], + }) var purchases = await GodotIapPlugin.get_available_purchases() _assert_equal(purchases.size(), 1, "Array envelopes should map to typed purchases") _assert_true(purchases[0] is Types.PurchaseAndroid, "Android purchases should be PurchaseAndroid") _assert_equal(purchases[0].is_acknowledged_android, true, "isAcknowledged should normalize for available purchases too") - - fake.responses["getAvailablePurchases"] = "{}" + var structured = await GodotIapPlugin.get_available_purchases_result() + _assert_equal(structured.get("success"), true, "Structured available-purchases results should preserve success") + _assert_equal(structured.get("purchases", []).size(), 1, "Structured results should contain the typed purchases") + + fake.responses["getAvailablePurchasesResult"] = "{}" + var failed = await GodotIapPlugin.get_available_purchases_result() + _assert_equal(failed.get("success"), false, "Missing success must remain distinguishable from an empty store") + _assert_equal(failed.get("code"), "service-error", "Native failure envelopes should preserve a service code") var not_an_array = await GodotIapPlugin.get_available_purchases() _assert_equal(not_an_array.size(), 0, "Non-array envelopes should degrade to an empty purchase list") + + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({ + "success": true, + "purchases": [{ + "id": "valid", + "productId": "valid", + "purchaseState": "purchased", + "store": "google", + "transactionDate": 1.0, + "quantity": 1.0, + "isAutoRenewing": false, + }, {"productId": "broken"}], + }) + var malformed = await GodotIapPlugin.get_available_purchases_result() + _assert_equal(malformed.get("success"), false, "One malformed purchase should reject the full batch") + _assert_equal(malformed.get("code"), "billing-response-json-parse-error", "Malformed batches should use the decode error code") + + fake.responses["getAvailablePurchasesResult"] = JSON.stringify({ + "success": true, + "purchases": [{ + "id": "foreign", + "productId": "foreign", + "transactionId": "foreign", + "purchaseState": "purchased", + "store": "apple", + "transactionDate": 1.0, + "quantity": 1, + "isAutoRenewing": false, + }], + }) + var foreign = await GodotIapPlugin.get_available_purchases_result() + _assert_equal(foreign.get("success"), false, "Android results should reject foreign stores") + _assert_equal(foreign.get("code"), "billing-response-json-parse-error", "Foreign stores should use the decode error code") + + var restore_errors: Array[Dictionary] = [] + var capture_restore_error = func(error: Dictionary) -> void: + restore_errors.append(error) + GodotIapPlugin.purchase_error.connect(capture_restore_error) + var restored = await GodotIapPlugin.restore_purchases() + _assert_equal(restored.success, false, "Restore should fail when available purchases cannot be decoded") + _assert_equal(restore_errors.size(), 1, "Failed Android restore should emit exactly one purchase_error") + GodotIapPlugin.purchase_error.disconnect(capture_restore_error) _uninstall_fake() func test_android_active_subscriptions_envelope() -> void: var fake = _install_android_fake() - fake.responses["getActiveSubscriptions"] = JSON.stringify([ - { + fake.responses["getActiveSubscriptionsResult"] = JSON.stringify({ + "success": true, + "subscriptions": [{ "productId": "sub.gold", "isActive": true, "transactionId": "txn-11", "transactionDate": 1720000000000.0, "autoRenewingAndroid": true, - }, - ]) + }], + }) var subscriptions = await GodotIapPlugin.get_active_subscriptions() _assert_equal(subscriptions.size(), 1, "Subscription envelopes should map to typed results") @@ -649,20 +864,28 @@ func test_android_active_subscriptions_envelope() -> void: var filter: Array[String] = ["sub.gold"] await GodotIapPlugin.get_active_subscriptions(filter) _assert_equal(fake.last_args[0], JSON.stringify(["sub.gold"]), "Non-empty filters should serialize to JSON") + + fake.responses["getActiveSubscriptionsResult"] = JSON.stringify({ + "success": true, + "subscriptions": [{"productId": "broken"}], + }) + var malformed = await GodotIapPlugin.get_active_subscriptions_result() + _assert_equal(malformed.get("success"), false, "Malformed subscription batches should fail atomically") + _assert_equal(malformed.get("code"), "billing-response-json-parse-error", "Malformed subscriptions should use the decode error code") _uninstall_fake() func test_android_has_active_subscriptions_envelope() -> void: var fake = _install_android_fake() - fake.responses["hasActiveSubscriptions"] = JSON.stringify({"hasActive": true}) + fake.responses["hasActiveSubscriptions"] = JSON.stringify({"success": true, "hasActive": true}) _assert_true(await GodotIapPlugin.has_active_subscriptions(), "hasActive envelopes should map to true") - # A malformed envelope must fall back to counting active subscriptions. + # A malformed envelope must not become a false entitlement result. fake.responses["hasActiveSubscriptions"] = "[]" - fake.responses["getActiveSubscriptions"] = JSON.stringify([ - {"productId": "sub.gold", "isActive": true, "transactionId": "txn-12", "transactionDate": 1.0}, - ]) - _assert_true(await GodotIapPlugin.has_active_subscriptions(), "Malformed envelopes should fall back to the subscription list") + var malformed = await GodotIapPlugin.has_active_subscriptions_result() + _assert_equal(malformed.get("success"), false, "Malformed status envelopes should remain failures") + _assert_equal(malformed.get("code"), "billing-response-json-parse-error", "Malformed status should use the decode error code") + _assert_false(await GodotIapPlugin.has_active_subscriptions(), "Compatibility status should still map failure to false") _uninstall_fake() diff --git a/libraries/godot-iap/Example/tests/test_godot_iap.gd b/libraries/godot-iap/Example/tests/test_godot_iap.gd index 413571fa2..90835b752 100644 --- a/libraries/godot-iap/Example/tests/test_godot_iap.gd +++ b/libraries/godot-iap/Example/tests/test_godot_iap.gd @@ -25,9 +25,12 @@ class FakeAndroidPlugin: last_purchase = JSON.parse_string(params_json) return JSON.stringify({"success": true, "pending": true}) - func getAvailablePurchasesWithOptions(options_json: String) -> String: + func getAvailablePurchasesResult() -> String: + return JSON.stringify({"success": true, "purchases": []}) + + func getAvailablePurchasesResultWithOptions(options_json: String) -> String: last_purchase_options = JSON.parse_string(options_json) - return "[]" + return JSON.stringify({"success": true, "purchases": []}) func getStorefrontAndroid() -> String: return storefront_result @@ -95,7 +98,6 @@ class FakeIOSAsyncPlugin: }]), }) - func _init() -> void: _run_suite.call_deferred() diff --git a/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar index ab05073b1..de2fa6adc 100644 Binary files a/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar and b/libraries/godot-iap/addons/godot-iap/android/GodotIap.release.aar differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap index 3ff611c93..22408e6cb 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap and b/libraries/godot-iap/addons/godot-iap/bin/ios/GodotIap.framework/GodotIap differ diff --git a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime index 9f8acdc1d..c2cace105 100755 Binary files a/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime and b/libraries/godot-iap/addons/godot-iap/bin/ios/SwiftGodotRuntime.framework/SwiftGodotRuntime differ diff --git a/libraries/godot-iap/addons/godot-iap/godot_iap.gd b/libraries/godot-iap/addons/godot-iap/godot_iap.gd index cafcc3286..40bd0e263 100644 --- a/libraries/godot-iap/addons/godot-iap/godot_iap.gd +++ b/libraries/godot-iap/addons/godot-iap/godot_iap.gd @@ -12,6 +12,33 @@ class_name GodotIapWrapper # Types from OpenIAP spec const Types = preload("types.gd") +const IOS_ASYNC_RESULT_CACHE_LIMIT := 64 +const IOS_ASYNC_TERMINAL_CACHE_LIMIT := 128 + + +class IosAsyncWaiter: + extends RefCounted + signal completed(payload: Dictionary) + var is_completed := false + var _timeout_timer = null + var _timeout_callback := Callable() + + func arm_timeout(timer, callback: Callable) -> void: + _timeout_timer = timer + _timeout_callback = callback + timer.timeout.connect(callback, CONNECT_ONE_SHOT) + + func complete(payload: Dictionary) -> void: + if is_completed: + return + is_completed = true + if _timeout_timer != null \ + and _timeout_timer.timeout.is_connected(_timeout_callback): + _timeout_timer.timeout.disconnect(_timeout_callback) + _timeout_timer = null + _timeout_callback = Callable() + completed.emit(payload) + # ========================================== # Signals (OpenIAP Events) # ========================================== @@ -38,6 +65,14 @@ var _is_connected: bool = false static var _is_initialized: bool = false var _purchase_updated_listener_options: Dictionary = {} var _ios_async_results: Dictionary = {} +var _ios_async_result_order: Array[String] = [] +var _ios_async_waiters: Dictionary = {} +var _ios_async_terminal_keys: Dictionary = {} +var _ios_async_terminal_order: Array[String] = [] +var _ios_async_cancellation_generation := 0 +var _ios_async_timeout_seconds := 30.0 +var _ios_async_restore_timeout_seconds := 120.0 +var _ios_async_ui_timeout_seconds := 300.0 # Platform detection var _platform: String = "" @@ -49,6 +84,13 @@ func _ready() -> void: _platform = OS.get_name() _init_native_plugin() + +func _exit_tree() -> void: + _cancel_pending_ios_async( + "service-disconnected", + "The IAP wrapper left the scene tree before the iOS operation completed" + ) + func _init_native_plugin() -> void: print("[GodotIap] Initializing native plugin...") print("[GodotIap] Platform: ", _platform) @@ -160,7 +202,15 @@ func _on_products_fetched(result: Dictionary) -> void: var method = String(result.get("method", "")) var request_id = String(result.get("requestId", "")) if not method.is_empty() and not request_id.is_empty(): - _ios_async_results[_ios_async_result_key(method, request_id)] = result + var cache_key := _ios_async_result_key(method, request_id) + if _ios_async_terminal_keys.has(cache_key): + return + if _ios_async_waiters.has(cache_key): + var waiter = _ios_async_waiters[cache_key] + if waiter is IosAsyncWaiter: + waiter.complete(result) + elif not _ios_async_terminal_keys.has(cache_key): + _cache_ios_async_result(cache_key, result) products_fetched.emit(result) func _on_connected(_status_code: int = 0) -> void: @@ -169,6 +219,11 @@ func _on_connected(_status_code: int = 0) -> void: func _on_disconnected(_status_code: int = 0) -> void: _is_connected = false + _cancel_pending_ios_async( + "service-disconnected", + "The store disconnected before the iOS operation completed", + "endConnection" + ) disconnected.emit() func _on_native_promoted_product_ios(product_id: String) -> void: @@ -266,6 +321,10 @@ func end_connection() -> bool: print("[GodotIap] end_connection called") if _native_plugin: if _platform == "iOS": + _cancel_pending_ios_async( + "service-disconnected", + "The store connection ended before the iOS operation completed" + ) var payload = await _call_ios_async("endConnection") if not payload.get("success", false): return false @@ -449,35 +508,61 @@ func _fetch_products_raw(request: Dictionary) -> Dictionary: ## ## See: https://openiap.dev/docs/apis/request-purchase func request_purchase(props) -> Variant: - var result = _request_purchase_raw(props.to_dict()) + var result = _request_purchase_raw(_as_dictionary(props)) + if result.get("status", "") == "pending" or result.get("pending", false): + return null if result.get("success", false): if _platform == "Android": return Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(result)) elif _platform == "iOS": return Types.PurchaseIOS.from_dict(_normalize_purchase_dict(result)) + # A success envelope on an unrecognized platform cannot be mapped to a + # typed purchase. Report it instead of returning a bare null, which is + # the silent-failure shape this contract forbids. + _purchase_failure( + "feature-not-supported", + "Purchase succeeded on an unsupported platform: %s" % _platform + ) return null + +func _purchase_failure(code: String, message: String, details: Dictionary = {}) -> Dictionary: + var error_payload := { + "code": code, + "message": message, + } + for key in details: + if key not in ["success", "error", "code", "message"]: + error_payload[key] = details[key] + purchase_error.emit(error_payload) + var result := error_payload.duplicate() + result["success"] = false + result["error"] = message + return result + ## Internal: Request a purchase with raw Dictionary func _request_purchase_raw(args: Dictionary) -> Dictionary: print("[GodotIap] _request_purchase_raw called") if not _native_plugin: print("[GodotIap] ERROR: Native plugin not available. Cannot make purchases.") - purchase_error.emit({ "code": "not-prepared", "message": "Native plugin not available" }) - return { "success": false, "error": "Native plugin not available" } + return _purchase_failure("not-prepared", "Native plugin not available") if args.has("requestPurchase") and args.has("requestSubscription"): - return { - "success": false, - "error": "Invalid request: choose either requestPurchase or requestSubscription" - } + return _purchase_failure( + "developer-error", + "Invalid request: choose either requestPurchase or requestSubscription" + ) if not args.has("requestPurchase") and not args.has("requestSubscription"): - return { - "success": false, - "error": "Invalid request: requestPurchase or requestSubscription is required" - } + return _purchase_failure( + "developer-error", + "Invalid request: requestPurchase or requestSubscription is required" + ) var request = args.get("requestPurchase", args.get("requestSubscription")) if not request is Dictionary: - return { "success": false, "error": "Invalid request: platform payload must be a Dictionary" } + return _purchase_failure( + "developer-error", + "Invalid request: platform payload must be a Dictionary" + ) var default_purchase_type := "subs" if args.has("requestSubscription") else "in-app" var purchase_type := _normalize_product_query_type( args.get("type", default_purchase_type), @@ -485,11 +570,11 @@ func _request_purchase_raw(args: Dictionary) -> Dictionary: false ) if purchase_type.is_empty(): - return { "success": false, "error": "Invalid purchase type" } + return _purchase_failure("developer-error", "Invalid purchase type") if args.has("requestPurchase") and purchase_type != "in-app": - return { "success": false, "error": "requestPurchase requires type `in-app`" } + return _purchase_failure("developer-error", "requestPurchase requires type `in-app`") if args.has("requestSubscription") and purchase_type != "subs": - return { "success": false, "error": "requestSubscription requires type `subs`" } + return _purchase_failure("developer-error", "requestSubscription requires type `subs`") var result_raw = null if _platform == "Android": @@ -497,7 +582,10 @@ func _request_purchase_raw(args: Dictionary) -> Dictionary: # delivers the final state via purchase_updated / purchase_error. var google_props = request.get("google", {}) if not google_props is Dictionary: - return { "success": false, "error": "Invalid request: google payload must be a Dictionary" } + return _purchase_failure( + "developer-error", + "Invalid request: google payload must be a Dictionary" + ) var offer_token = google_props.get("offerToken", "") var subscription_offers = google_props.get("subscriptionOffers", []) var replacement_params = google_props.get("subscriptionProductReplacementParams", null) @@ -526,10 +614,13 @@ func _request_purchase_raw(args: Dictionary) -> Dictionary: elif _platform == "iOS": var apple_props = request.get("apple", {}) if not apple_props is Dictionary: - return { "success": false, "error": "Invalid request: apple payload must be a Dictionary" } + return _purchase_failure( + "developer-error", + "Invalid request: apple payload must be a Dictionary" + ) var sku = apple_props.get("sku", "") if sku.is_empty(): - return { "success": false, "error": "Invalid request: SKU is required" } + return _purchase_failure("developer-error", "Invalid request: SKU is required") var ios_payload = { "type": purchase_type } if purchase_type == "subs": ios_payload["requestSubscription"] = { "apple": apple_props } @@ -537,24 +628,32 @@ func _request_purchase_raw(args: Dictionary) -> Dictionary: ios_payload["requestPurchase"] = { "apple": apple_props } result_raw = _native_plugin.call("requestPurchaseWithPayload", JSON.stringify(ios_payload)) else: - return { "success": false, "error": "Unsupported platform" } + return _purchase_failure("feature-not-supported", "Unsupported platform") if result_raw == null or str(result_raw) == "": var err_msg = "requestPurchase returned empty. Billing may not be connected." print("[GodotIap] ERROR: ", err_msg) - purchase_error.emit({ "code": "service-error", "message": err_msg }) - return { "success": false, "error": err_msg } + return _purchase_failure("service-error", err_msg) var result_json = str(result_raw) print("[GodotIap] requestPurchase result received") var result = JSON.parse_string(result_json) if result is Dictionary: - if not result.get("success", false) and result.has("error"): - print("[GodotIap] requestPurchase error: ", result.get("error")) - purchase_error.emit({ "code": result.get("code", "unknown"), "message": result.get("error", "Unknown error") }) - return result + if result.get("success", false): + return result + if result.get("status", "") == "pending": + return result + var error_message := String( + result.get("error", "requestPurchase returned an unsuccessful response") + ) + print("[GodotIap] requestPurchase error: ", error_message) + return _purchase_failure( + String(result.get("code", "unknown")), + error_message, + result + ) print("[GodotIap] requestPurchase parse error") - return { "success": false, "error": "Failed to parse response" } + return _purchase_failure("service-error", "Failed to parse response") ## Complete a purchase transaction. Call after server-side verification. ## @@ -628,14 +727,33 @@ func restore_purchases() -> Variant: print("[GodotIap] restore_purchases called") if _platform == "iOS" and _native_plugin: - var payload = await _call_ios_async("restorePurchases") + var payload = await _call_ios_async( + "restorePurchases", + [], + _ios_async_restore_timeout_seconds + ) var ios_result = Types.VoidResult.new() ios_result.success = payload.get("success", false) + # The non-iOS path below reports a failed restore through + # purchase_error. Emit it here too, otherwise a caller that only + # listens to the signal sees Android restore failures but not iOS ones. + if not ios_result.success: + _purchase_failure( + String(payload.get("code", "service-error")), + String(payload.get("error", "Failed to restore purchases")), + payload + ) return ios_result - await get_available_purchases() + var available_result := await get_available_purchases_result() var result = Types.VoidResult.new() - result.success = true + result.success = available_result.get("success", false) + if not result.success: + _purchase_failure( + String(available_result.get("code", "service-error")), + String(available_result.get("error", "Failed to restore purchases")), + available_result + ) return result ## List the user's unfinished purchases — non-consumables, active subscriptions, and any @@ -644,7 +762,10 @@ func restore_purchases() -> Variant: ## [param options] (optional): [PurchaseOptions]. iOS-only flags ## ([code]also_publish_to_event_listener_ios[/code], [code]only_include_active_items_ios[/code]). ## -## Returns [Array][[Purchase]] currently held by the store. +## Returns [Array][[Purchase]] currently held by the store. This compatibility +## method maps native/bridge failures to an empty array. Entitlement and restore +## flows must use [method get_available_purchases_result] so a failure cannot be +## mistaken for an authoritative empty store result. ## ## [codeblock] ## var purchases = await iap.get_available_purchases() @@ -656,17 +777,38 @@ func restore_purchases() -> Variant: ## See: https://openiap.dev/docs/apis/get-available-purchases func get_available_purchases(options = null) -> Array: print("[GodotIap] get_available_purchases called") - var raw_purchases = await _get_available_purchases_raw(options) + var result := await get_available_purchases_result(options) + return result.get("purchases", []) if result.get("success", false) else [] + + +## List available purchases while preserving the distinction between an +## authoritative empty store result and a store/bridge failure. +## +## Returns a Dictionary with `success`, `purchases`, and, on failure, `code` +## plus `error`. Neither this method nor [method get_available_purchases] emits +## [signal purchase_error] — entitlement reads are queries, not purchase +## attempts, so callers own the failure policy. The difference is the return +## shape: [method get_available_purchases] collapses a failure into an empty +## array, while this method preserves it. +func get_available_purchases_result(options = null) -> Dictionary: + print("[GodotIap] get_available_purchases_result called") + var raw_result := await _get_available_purchases_result_raw(options) + if not raw_result.get("success", false): + return raw_result + + var raw_purchases = raw_result.get("purchases", []) var purchases: Array = [] for purchase_dict in raw_purchases: - if purchase_dict is Dictionary: - if _platform == "Android": - purchases.append(Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(purchase_dict))) - elif _platform == "iOS": - purchases.append(Types.PurchaseIOS.from_dict(_normalize_purchase_dict(purchase_dict))) + if _platform == "Android": + purchases.append(Types.PurchaseAndroid.from_dict(_normalize_android_purchase_dict(purchase_dict))) + elif _platform == "iOS": + purchases.append(Types.PurchaseIOS.from_dict(_normalize_purchase_dict(purchase_dict))) - return purchases + return { + "success": true, + "purchases": purchases, + } func _normalize_purchase_dict(purchase_dict: Dictionary) -> Dictionary: @@ -695,36 +837,139 @@ func _as_dictionary(value) -> Dictionary: return {} -## Internal: Get available purchases raw +## Internal compatibility helper. Prefer `_get_available_purchases_result_raw` +## so failures cannot be mistaken for an authoritative empty result. func _get_available_purchases_raw(options = null) -> Array: - if _native_plugin: - var options_dict := _as_dictionary(options) - if _platform == "Android": - var result_json - if options == null: - result_json = _native_plugin.call("getAvailablePurchases") - else: - result_json = _native_plugin.call( - "getAvailablePurchasesWithOptions", - JSON.stringify(options_dict) - ) - var result = JSON.parse_string(result_json) - if result is Array: - return result - return [] - elif _platform == "iOS": - var payload = await _call_ios_async( - "getAvailablePurchases", - [JSON.stringify(options_dict)] - ) - if payload.get("success", false): - var purchases = JSON.parse_string(payload.get("purchasesJson", "[]")) - if purchases is Array: - return purchases - return [] - # No native plugin + var result := await _get_available_purchases_result_raw(options) + if result.get("success", false): + return result.get("purchases", []) return [] + +func _get_available_purchases_result_raw(options = null) -> Dictionary: + if not _native_plugin: + return { + "success": false, + "code": "not-prepared", + "error": "Native plugin not available", + } + + var options_dict := _as_dictionary(options) + if _platform == "Android": + var result_json + if options == null: + result_json = _native_plugin.call("getAvailablePurchasesResult") + else: + result_json = _native_plugin.call( + "getAvailablePurchasesResultWithOptions", + JSON.stringify(options_dict) + ) + var result = JSON.parse_string(result_json) + if not result is Dictionary: + return { + "success": false, + "code": "billing-response-json-parse-error", + "error": "Failed to parse the Android available-purchases response", + } + if not result.get("success", false): + return { + "success": false, + "code": String(result.get("code", "service-error")), + "error": String(result.get("error", "Failed to get available purchases")), + } + return _validated_purchase_batch(result.get("purchases", null), "Android") + + if _platform == "iOS": + var payload = await _call_ios_async( + "getAvailablePurchases", + [JSON.stringify(options_dict)] + ) + if not payload.get("success", false): + return { + "success": false, + "code": String(payload.get("code", "service-error")), + "error": String(payload.get("error", "Failed to get available purchases")), + } + var purchases = JSON.parse_string(payload.get("purchasesJson", "")) + return _validated_purchase_batch(purchases, "iOS") + + return { + "success": false, + "code": "feature-not-supported", + "error": "Unsupported platform", + } + + +func _validated_purchase_batch(value, platform_name: String) -> Dictionary: + if not value is Array: + return { + "success": false, + "code": "billing-response-json-parse-error", + "error": "%s returned a malformed available-purchases payload" % platform_name, + } + for item in value: + if not _is_valid_purchase_dictionary(item): + return { + "success": false, + "code": "billing-response-json-parse-error", + "error": "%s returned a malformed purchase item" % platform_name, + } + return { + "success": true, + "purchases": value, + } + + +func _is_valid_purchase_dictionary(value) -> bool: + if not value is Dictionary: + return false + for required_string in ["id", "productId", "store", "purchaseState"]: + if not value.get(required_string) is String \ + or String(value.get(required_string)).is_empty(): + return false + var store := String(value.get("store")) + if _platform == "iOS" and store != "apple": + return false + if _platform == "Android" and store not in ["google", "amazon", "horizon"]: + return false + var transaction_date = value.get("transactionDate") + if not transaction_date is float and not transaction_date is int: + return false + if transaction_date is float and not is_finite(transaction_date): + return false + var quantity = value.get("quantity") + if not quantity is int and not ( + quantity is float \ + and is_finite(quantity) \ + and quantity == floor(quantity) + ): + return false + if not value.get("isAutoRenewing") is bool: + return false + if value.has("ids"): + if not value.get("ids") is Array: + return false + for id in value.get("ids"): + if not id is String: + return false + for optional_object in [ + "pendingPurchaseUpdateAndroid", + "offerIOS", + "renewalInfoIOS", + "commitmentInfoIOS", + "advancedCommerceInfoIOS", + ]: + if value.has(optional_object) \ + and value.get(optional_object) != null \ + and not value.get(optional_object) is Dictionary: + return false + if _platform == "iOS" and ( + not value.get("transactionId") is String \ + or String(value.get("transactionId")).is_empty() + ): + return false + return true + # ========================================== # Subscriptions (OpenIAP Query) # ========================================== @@ -736,36 +981,117 @@ func _get_available_purchases_raw(options = null) -> Array: ## See: https://openiap.dev/docs/apis/get-active-subscriptions func get_active_subscriptions(subscription_ids: Array[String] = []) -> Array: print("[GodotIap] get_active_subscriptions called") - var raw_subs = await _get_active_subscriptions_raw(subscription_ids) - var subscriptions: Array = [] + var result := await get_active_subscriptions_result(subscription_ids) + return result.get("subscriptions", []) if result.get("success", false) else [] + - for sub_dict in raw_subs: - if sub_dict is Dictionary: - subscriptions.append(Types.ActiveSubscription.from_dict(sub_dict)) +## Get active subscriptions while preserving store and bridge failures. +## Returns `success`, `subscriptions`, and, on failure, `code` plus `error`. +func get_active_subscriptions_result( + subscription_ids: Array[String] = [] +) -> Dictionary: + print("[GodotIap] get_active_subscriptions_result called") + var raw_result := await _get_active_subscriptions_result_raw(subscription_ids) + if not raw_result.get("success", false): + return raw_result - return subscriptions + var subscriptions: Array = [] + for sub_dict in raw_result.get("subscriptions", []): + subscriptions.append(Types.ActiveSubscription.from_dict(sub_dict)) + return { + "success": true, + "subscriptions": subscriptions, + } ## Internal: Get active subscriptions raw func _get_active_subscriptions_raw(subscription_ids: Array = []) -> Array: - if _native_plugin: - if _platform == "Android": - var ids_json = JSON.stringify(subscription_ids) if subscription_ids.size() > 0 else null - var result_json = _native_plugin.call("getActiveSubscriptions", ids_json) - var result = JSON.parse_string(result_json) - if result is Array: - return result - return [] - elif _platform == "iOS": - var ids_json = JSON.stringify(subscription_ids) if subscription_ids.size() > 0 else "" - var payload = await _call_ios_async("getActiveSubscriptions", [ids_json]) - if payload.get("success", false): - var subscriptions = JSON.parse_string(payload.get("subscriptionsJson", "[]")) - if subscriptions is Array: - return subscriptions - return [] - # No native plugin + var result := await _get_active_subscriptions_result_raw(subscription_ids) + if result.get("success", false): + return result.get("subscriptions", []) return [] + +func _get_active_subscriptions_result_raw(subscription_ids: Array = []) -> Dictionary: + if not _native_plugin: + return { + "success": false, + "code": "not-prepared", + "error": "Native plugin not available", + } + var ids_json = JSON.stringify(subscription_ids) if subscription_ids.size() > 0 else ("" if _platform == "iOS" else null) + if _platform == "Android": + var result = JSON.parse_string( + _native_plugin.call("getActiveSubscriptionsResult", ids_json) + ) + if not result is Dictionary: + return _active_subscription_failure( + "billing-response-json-parse-error", + "Failed to parse the Android active-subscriptions response" + ) + if not result.get("success", false): + return _active_subscription_failure( + String(result.get("code", "service-error")), + String(result.get("error", "Failed to get active subscriptions")) + ) + return _validated_active_subscription_batch(result.get("subscriptions", null), "Android") + if _platform == "iOS": + var payload = await _call_ios_async("getActiveSubscriptions", [ids_json]) + if not payload.get("success", false): + return _active_subscription_failure( + String(payload.get("code", "service-error")), + String(payload.get("error", "Failed to get active subscriptions")) + ) + var subscriptions = JSON.parse_string(payload.get("subscriptionsJson", "")) + return _validated_active_subscription_batch(subscriptions, "iOS") + return _active_subscription_failure("feature-not-supported", "Unsupported platform") + + +func _validated_active_subscription_batch(value, platform_name: String) -> Dictionary: + if not value is Array: + return _active_subscription_failure( + "billing-response-json-parse-error", + "%s returned a malformed active-subscriptions payload" % platform_name + ) + for item in value: + if not _is_valid_active_subscription_dictionary(item): + return _active_subscription_failure( + "billing-response-json-parse-error", + "%s returned a malformed active subscription" % platform_name + ) + return { + "success": true, + "subscriptions": value, + } + + +func _is_valid_active_subscription_dictionary(value) -> bool: + if not value is Dictionary: + return false + for required_string in ["productId", "transactionId"]: + if not value.get(required_string) is String \ + or String(value.get(required_string)).is_empty(): + return false + if not value.get("isActive") is bool: + return false + var transaction_date = value.get("transactionDate") + if not transaction_date is float and not transaction_date is int: + return false + if transaction_date is float and not is_finite(transaction_date): + return false + if value.has("renewalInfoIOS") \ + and value.get("renewalInfoIOS") != null \ + and not value.get("renewalInfoIOS") is Dictionary: + return false + return true + + +func _active_subscription_failure(code: String, message: String) -> Dictionary: + return { + "success": false, + "code": code, + "error": message, + } + ## Check if user has any active subscriptions. ## @param subscription_ids: Array[String] - optional array of subscription IDs to check ## @return bool - true if any subscription is active @@ -773,6 +1099,15 @@ func _get_active_subscriptions_raw(subscription_ids: Array = []) -> Array: ## See: https://openiap.dev/docs/apis/has-active-subscriptions func has_active_subscriptions(subscription_ids: Array[String] = []) -> bool: print("[GodotIap] has_active_subscriptions called") + var result := await has_active_subscriptions_result(subscription_ids) + return result.get("hasActive", false) if result.get("success", false) else false + + +## Check active-subscription status without turning a query failure into false. +func has_active_subscriptions_result( + subscription_ids: Array[String] = [] +) -> Dictionary: + print("[GodotIap] has_active_subscriptions_result called") if _native_plugin and (_platform == "Android" or _platform == "iOS"): var ids_json = JSON.stringify(subscription_ids) if subscription_ids.size() > 0 else ("" if _platform == "iOS" else null) var result = null @@ -780,14 +1115,29 @@ func has_active_subscriptions(subscription_ids: Array[String] = []) -> bool: result = await _call_ios_async("hasActiveSubscriptions", [ids_json]) else: result = JSON.parse_string(_native_plugin.call("hasActiveSubscriptions", ids_json)) - if result is Dictionary: - return result.get("hasActive", false) - # Fallback: check manually - var subscriptions = await get_active_subscriptions(subscription_ids) - for sub in subscriptions: - if sub.is_active: - return true - return false + if not result is Dictionary: + return _active_subscription_failure( + "billing-response-json-parse-error", + "Failed to parse the active-subscription status response" + ) + if not result.get("success", false): + return _active_subscription_failure( + String(result.get("code", "service-error")), + String(result.get("error", "Failed to check active subscriptions")) + ) + if not result.get("hasActive") is bool: + return _active_subscription_failure( + "billing-response-json-parse-error", + "Native bridge returned an invalid active-subscription status" + ) + return { + "success": true, + "hasActive": result.get("hasActive"), + } + return _active_subscription_failure( + "not-prepared" if _platform == "Android" or _platform == "iOS" else "feature-not-supported", + "Active-subscription status requires a native store plugin" + ) # ========================================== # Storefront (OpenIAP Query) @@ -998,7 +1348,9 @@ func get_all_transactions_ios() -> Array: func present_code_redemption_sheet_ios() -> Variant: if not (_native_plugin and _platform == "iOS"): return null - var payload = await _call_ios_async("presentCodeRedemptionSheetIOS") + var payload = await _call_ios_async( + "presentCodeRedemptionSheetIOS", [], _ios_async_ui_timeout_seconds + ) if not payload.get("success", false): return null var purchase_json = payload.get("purchaseJson", "") @@ -1015,7 +1367,9 @@ func present_code_redemption_sheet_ios() -> Variant: func show_manage_subscriptions_ios() -> Array: var purchases: Array = [] if _native_plugin and _platform == "iOS": - var payload = await _call_ios_async("showManageSubscriptionsIOS") + var payload = await _call_ios_async( + "showManageSubscriptionsIOS", [], _ios_async_ui_timeout_seconds + ) if payload.get("success", false): var purchases_json = payload.get("purchasesJson", "[]") var parsed = JSON.parse_string(purchases_json) @@ -1033,7 +1387,9 @@ func show_manage_subscriptions_ios() -> Array: func begin_refund_request_ios(product_id: String) -> String: if not (_native_plugin and _platform == "iOS"): return "" - var payload = await _call_ios_async("beginRefundRequestIOS", [product_id]) + var payload = await _call_ios_async( + "beginRefundRequestIOS", [product_id], _ios_async_ui_timeout_seconds + ) if payload.get("success", false): return payload.get("status", "") return "" @@ -1144,7 +1500,9 @@ func can_present_external_purchase_notice_ios() -> bool: ## See: https://openiap.dev/docs/apis/ios/present-external-purchase-notice-sheet-ios func present_external_purchase_notice_sheet_ios() -> Variant: if _native_plugin and _platform == "iOS": - var payload = await _call_ios_async("presentExternalPurchaseNoticeSheetIOS") + var payload = await _call_ios_async( + "presentExternalPurchaseNoticeSheetIOS", [], _ios_async_ui_timeout_seconds + ) if payload.get("success", false): var decoded = JSON.parse_string(payload.get("resultJson", "{}")) if decoded is Dictionary: @@ -1159,7 +1517,9 @@ func present_external_purchase_notice_sheet_ios() -> Variant: ## See: https://openiap.dev/docs/apis/ios/present-external-purchase-link-ios func present_external_purchase_link_ios(url: String) -> Variant: if _native_plugin and _platform == "iOS": - var payload = await _call_ios_async("presentExternalPurchaseLinkIOS", [url]) + var payload = await _call_ios_async( + "presentExternalPurchaseLinkIOS", [url], _ios_async_ui_timeout_seconds + ) if payload.get("success", false): var decoded = JSON.parse_string(payload.get("resultJson", "{}")) if decoded is Dictionary: @@ -1202,28 +1562,145 @@ func get_transaction_jws_ios(sku: String) -> String: return "" ## Await the completion matching the native method and request token. -func _await_products_fetched_for(method: String, request_id: String) -> Dictionary: - var cache_key = _ios_async_result_key(method, request_id) - while true: - if _ios_async_results.has(cache_key): - var cached = _ios_async_results[cache_key] - _ios_async_results.erase(cache_key) - return cached as Dictionary - var payload = await products_fetched - if payload is Dictionary \ - and payload.get("method", "") == method \ - and payload.get("requestId", "") == request_id: - _ios_async_results.erase(cache_key) - return payload as Dictionary +func _await_products_fetched_for( + method: String, + request_id: String, + timeout_seconds: float = -1.0 +) -> Dictionary: + var cache_key := _ios_async_result_key(method, request_id) + if _ios_async_results.has(cache_key): + var cached = _take_cached_ios_async_result(cache_key) + _mark_ios_async_terminal(cache_key) + return cached + if _ios_async_terminal_keys.has(cache_key): + return { + "success": false, + "code": "service-error", + "error": "%s completion is no longer available" % method, + "method": method, + "requestId": request_id, + } + + var waiter := IosAsyncWaiter.new() + _ios_async_waiters[cache_key] = waiter + + # `_on_products_fetched` can cache an immediate native completion before + # the requestId-returning call finishes. Consume it after installing the + # request-scoped waiter so neither ordering can lose the completion. + if _ios_async_results.has(cache_key): + var cached = _take_cached_ios_async_result(cache_key) + _ios_async_waiters.erase(cache_key) + _mark_ios_async_terminal(cache_key) + return cached + + var effective_timeout := timeout_seconds + if effective_timeout <= 0.0: + effective_timeout = _ios_async_timeout_seconds + # `create_timer` needs a live SceneTree. Outside it — during shutdown, or + # before the autoload is attached — no timeout could ever fire, so waiting + # here would reintroduce the unbounded wait this guard exists to prevent. + var tree := get_tree() + if tree == null: + _ios_async_waiters.erase(cache_key) + _mark_ios_async_terminal(cache_key) + return { + "success": false, + "code": "not-prepared", + "error": "%s cannot await a completion outside the scene tree" % method, + "method": method, + "requestId": request_id, + } + var timer := tree.create_timer(effective_timeout) + var timeout_callback := func() -> void: + _complete_ios_async_waiter(cache_key, { + "success": false, + "code": "service-timeout", + "error": "%s timed out after %.1f seconds" % [method, effective_timeout], + "method": method, + "requestId": request_id, + }) + waiter.arm_timeout(timer, timeout_callback) + + var payload = await waiter.completed + _ios_async_waiters.erase(cache_key) + _mark_ios_async_terminal(cache_key) + if payload is Dictionary: + return payload + return { + "success": false, + "code": "service-error", + "error": "%s returned an invalid completion" % method, + } + + +func _complete_ios_async_waiter(cache_key: String, payload: Dictionary) -> void: + if not _ios_async_waiters.has(cache_key): + return + var waiter = _ios_async_waiters[cache_key] + if waiter is IosAsyncWaiter: + waiter.complete(payload) + + +func _cancel_pending_ios_async( + code: String, + message: String, + excluded_method: String = "" +) -> void: + _ios_async_cancellation_generation += 1 + for cache_key in _ios_async_waiters.keys(): + if not excluded_method.is_empty() \ + and String(cache_key).begins_with("%s:" % excluded_method): + continue + _complete_ios_async_waiter(String(cache_key), { + "success": false, + "code": code, + "error": message, + }) + + +func _cache_ios_async_result(cache_key: String, payload: Dictionary) -> void: + if not _ios_async_results.has(cache_key): + _ios_async_result_order.append(cache_key) + _ios_async_results[cache_key] = payload + while _ios_async_result_order.size() > IOS_ASYNC_RESULT_CACHE_LIMIT: + var oldest := _ios_async_result_order.pop_front() + _ios_async_results.erase(oldest) + _mark_ios_async_terminal(oldest) + + +func _take_cached_ios_async_result(cache_key: String) -> Dictionary: + var payload = _ios_async_results.get(cache_key, {}) + _ios_async_results.erase(cache_key) + _ios_async_result_order.erase(cache_key) + if payload is Dictionary: + return payload return {} + +func _mark_ios_async_terminal(cache_key: String) -> void: + if not _ios_async_terminal_keys.has(cache_key): + _ios_async_terminal_keys[cache_key] = true + _ios_async_terminal_order.append(cache_key) + while _ios_async_terminal_order.size() > IOS_ASYNC_TERMINAL_CACHE_LIMIT: + var oldest := _ios_async_terminal_order.pop_front() + _ios_async_terminal_keys.erase(oldest) + ## Dispatch an iOS native method that returns a pending request token, then ## await its method/requestId-tagged completion. Native completions are cached ## by `_on_products_fetched` so a very fast Swift Task cannot emit before this ## coroutine installs its signal waiter and get lost. -func _call_ios_async(method: String, args: Array = []) -> Dictionary: +func _call_ios_async( + method: String, + args: Array = [], + timeout_seconds: float = -1.0 +) -> Dictionary: if not (_native_plugin and _platform == "iOS"): - return {"success": false, "error": "iOS native plugin is unavailable"} + return { + "success": false, + "code": "not-prepared", + "error": "iOS native plugin is unavailable", + } + var cancellation_generation := _ios_async_cancellation_generation var pending = _native_plugin.callv(method, args) var request_id = _parse_request_id(pending) if request_id.is_empty(): @@ -1231,8 +1708,22 @@ func _call_ios_async(method: String, args: Array = []) -> Dictionary: var immediate = JSON.parse_string(pending) if immediate is Dictionary: return immediate - return {"success": false, "error": "%s did not return a requestId" % method} - return await _await_products_fetched_for(method, request_id) + return { + "success": false, + "code": "service-error", + "error": "%s did not return a requestId" % method, + } + if method != "endConnection" \ + and cancellation_generation != _ios_async_cancellation_generation: + var cache_key := _ios_async_result_key(method, request_id) + _take_cached_ios_async_result(cache_key) + _mark_ios_async_terminal(cache_key) + return { + "success": false, + "code": "service-disconnected", + "error": "The store disconnected before %s could start waiting" % method, + } + return await _await_products_fetched_for(method, request_id, timeout_seconds) func _ios_async_result_key(method: String, request_id: String) -> String: return "%s:%s" % [method, request_id] @@ -1291,7 +1782,11 @@ func get_external_purchase_custom_link_token_ios(token_type: String) -> Variant: func show_external_purchase_custom_link_notice_ios(notice_type: String) -> Variant: if not (_native_plugin and _platform == "iOS"): return null - var payload = await _call_ios_async("showExternalPurchaseCustomLinkNoticeIOS", [notice_type]) + var payload = await _call_ios_async( + "showExternalPurchaseCustomLinkNoticeIOS", + [notice_type], + _ios_async_ui_timeout_seconds + ) if payload.get("success", false): var payload_json = payload.get("resultJson", "") var decoded = JSON.parse_string(payload_json) diff --git a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt index 7155fc0d7..b3af4192e 100644 --- a/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt +++ b/libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt @@ -361,18 +361,11 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { pluginScope.launch { try { store.requestPurchase(requestProps) - } catch (e: OpenIapError) { - GodotIapLog.failure("requestPurchase", e) - emitSignal("purchase_error", JSONObject(serializeOpenIapError(e)).toString()) } catch (e: Exception) { + // OpenIapModule owns purchase_error publication after dispatch. + // The bridge only observes the exception so one failed request + // cannot publish the same terminal error twice. GodotIapLog.failure("requestPurchase", e) - emitSignal( - "purchase_error", - JSONObject().apply { - put("code", "unknown") - put("message", e.message ?: "Unknown purchase error") - }.toString() - ) } } @@ -492,6 +485,79 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return getAvailablePurchasesInternal(options) } + /** + * Structured variant used by the GDScript wrapper to distinguish an + * authoritative empty purchase list from a store or bridge failure. + */ + @UsedByGodot + fun getAvailablePurchasesResult(): String = getAvailablePurchasesResultInternal(null) + + @UsedByGodot + fun getAvailablePurchasesResultWithOptions(optionsJson: String): String { + val options = try { + PurchaseOptions.fromJson( + GodotIapHelper.jsonObjectToMap(JSONObject(optionsJson)) + ) + } catch (error: Exception) { + GodotIapLog.failure("getAvailablePurchasesResultWithOptions", error) + return availablePurchasesFailure( + code = "developer-error", + message = error.message ?: "Invalid purchase options", + ) + } + return getAvailablePurchasesResultInternal(options) + } + + private fun getAvailablePurchasesResultInternal(options: PurchaseOptions?): String { + GodotIapLog.debug("getAvailablePurchasesResult called") + + if (!isInitialized) { + return availablePurchasesFailure( + code = "not-prepared", + message = "IAP connection is not initialized", + ) + } + + return runBlocking { + try { + val purchasesArray = JSONArray() + store.getAvailablePurchases(options).forEach { purchase -> + val sanitized = GodotIapHelper.sanitizeDictionary(purchase.toJson()) + purchasesArray.put(JSONObject(sanitized)) + } + + GodotIapLog.result( + "getAvailablePurchasesResult", + "count=${purchasesArray.length()}", + ) + JSONObject().apply { + put("success", true) + put("purchases", purchasesArray) + }.toString() + } catch (error: OpenIapError) { + GodotIapLog.failure("getAvailablePurchasesResult", error) + val payload = serializeOpenIapError(error) + availablePurchasesFailure( + code = payload["code"]?.toString() ?: "service-error", + message = error.message ?: "Failed to get available purchases", + ) + } catch (error: Exception) { + GodotIapLog.failure("getAvailablePurchasesResult", error) + availablePurchasesFailure( + code = "service-error", + message = error.message ?: "Failed to get available purchases", + ) + } + } + } + + private fun availablePurchasesFailure(code: String, message: String): String = + JSONObject().apply { + put("success", false) + put("code", code) + put("error", message) + }.toString() + private fun getAvailablePurchasesInternal(options: PurchaseOptions?): String { GodotIapLog.debug("getAvailablePurchases called") @@ -558,6 +624,61 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { } } + /** Structured entitlement query used by the failure-aware GDScript API. */ + @UsedByGodot + fun getActiveSubscriptionsResult(subscriptionIdsJson: String?): String { + GodotIapLog.debug("getActiveSubscriptionsResult called") + + if (!isInitialized) { + return activeSubscriptionsFailure( + code = "not-prepared", + message = "IAP connection is not initialized", + ) + } + + return runBlocking { + try { + val subscriptionIds = subscriptionIdsJson?.let { + val array = JSONArray(it) + val list = mutableListOf() + for (i in 0 until array.length()) { + list.add(array.getString(i)) + } + list.takeIf { ids -> ids.isNotEmpty() } + } + val subscriptionsArray = JSONArray() + store.getActiveSubscriptions(subscriptionIds).forEach { subscription -> + val sanitized = GodotIapHelper.sanitizeDictionary(subscription.toJson()) + subscriptionsArray.put(JSONObject(sanitized)) + } + JSONObject().apply { + put("success", true) + put("subscriptions", subscriptionsArray) + }.toString() + } catch (error: OpenIapError) { + GodotIapLog.failure("getActiveSubscriptionsResult", error) + val payload = serializeOpenIapError(error) + activeSubscriptionsFailure( + code = payload["code"]?.toString() ?: "service-error", + message = error.message ?: "Failed to get active subscriptions", + ) + } catch (error: Exception) { + GodotIapLog.failure("getActiveSubscriptionsResult", error) + activeSubscriptionsFailure( + code = "service-error", + message = error.message ?: "Failed to get active subscriptions", + ) + } + } + } + + private fun activeSubscriptionsFailure(code: String, message: String): String = + JSONObject().apply { + put("success", false) + put("code", code) + put("error", message) + }.toString() + @UsedByGodot fun hasActiveSubscriptions(subscriptionIdsJson: String?): String { GodotIapLog.debug("hasActiveSubscriptions called") @@ -566,6 +687,8 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { return JSONObject().apply { put("success", false) put("hasActive", false) + put("code", "not-prepared") + put("error", "IAP connection is not initialized") }.toString() } @@ -592,7 +715,14 @@ class GodotIap(godot: Godot) : GodotPlugin(godot) { JSONObject().apply { put("success", false) put("hasActive", false) - put("error", e.message) + put( + "code", + (e as? OpenIapError) + ?.let(::serializeOpenIapError) + ?.get("code") + ?: "service-error", + ) + put("error", e.message ?: "Failed to check active subscriptions") }.toString() } } diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift index 514bcbf59..820c4b227 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift @@ -312,6 +312,11 @@ public class GodotIap: RefCounted, @unchecked Sendable { GodotIapLog.failure("requestPurchaseWithPayload", error: error) } catch { GodotIapLog.failure("requestPurchaseWithPayload", error: error) + await self?.emitPurchaseError( + code: ErrorCode.purchaseError.rawValue, + message: error.localizedDescription, + productId: productId + ) } } @@ -424,7 +429,7 @@ public class GodotIap: RefCounted, @unchecked Sendable { options = try OpenIapSerialization.purchaseOptions(from: object) } let purchases = try await self.openIap.getAvailablePurchases(options) - let purchaseDicts = purchases.map { self.purchaseToDictionary($0) } + let purchaseDicts = try GodotIapHelper.purchasesRequired(purchases) if let jsonData = try? JSONSerialization.data(withJSONObject: purchaseDicts), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -443,6 +448,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { message: "Failed to serialize available purchases" ) } + } catch let error as PurchaseError { + GodotIapLog.debug("[GodotIap] getAvailablePurchases error: \(error.localizedDescription)") + await self.emitAsyncFailure( + method: "getAvailablePurchases", + requestId: requestId, + message: error.localizedDescription, + code: error.code.rawValue + ) } catch { GodotIapLog.debug("[GodotIap] getAvailablePurchases error: \(error.localizedDescription)") await self.emitAsyncFailure( @@ -474,7 +487,9 @@ public class GodotIap: RefCounted, @unchecked Sendable { } let subscriptions = try await self.openIap.getActiveSubscriptions(subscriptionIds) - let subDicts = subscriptions.map { OpenIapSerialization.encode($0) } + let subDicts = try subscriptions.map { + try GodotIapHelper.encodeRequired($0) + } if let jsonData = try? JSONSerialization.data(withJSONObject: subDicts), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -493,6 +508,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { message: "Failed to serialize active subscriptions" ) } + } catch let error as PurchaseError { + GodotIapLog.debug("[GodotIap] getActiveSubscriptions error: \(error.localizedDescription)") + await self.emitAsyncFailure( + method: "getActiveSubscriptions", + requestId: requestId, + message: error.localizedDescription, + code: error.code.rawValue + ) } catch { GodotIapLog.debug("[GodotIap] getActiveSubscriptions error: \(error.localizedDescription)") await self.emitAsyncFailure( @@ -531,6 +554,14 @@ public class GodotIap: RefCounted, @unchecked Sendable { dict["hasActive"] = Variant(hasActive) self.productsFetched.emit(dict) } + } catch let error as PurchaseError { + GodotIapLog.debug("[GodotIap] hasActiveSubscriptions error: \(error.localizedDescription)") + await self.emitAsyncFailure( + method: "hasActiveSubscriptions", + requestId: requestId, + message: error.localizedDescription, + code: error.code.rawValue + ) } catch { GodotIapLog.debug("[GodotIap] hasActiveSubscriptions error: \(error.localizedDescription)") await self.emitAsyncFailure( @@ -650,7 +681,9 @@ public class GodotIap: RefCounted, @unchecked Sendable { guard let self = self else { return } do { let transactions = try await self.openIap.getPendingTransactionsIOS() - let transactionDicts = transactions.map { self.purchaseIOSToDictionary($0) } + let transactionDicts = try transactions.map { + try GodotIapHelper.encodeRequired($0) + } if let jsonData = try? JSONSerialization.data(withJSONObject: transactionDicts), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -697,7 +730,9 @@ public class GodotIap: RefCounted, @unchecked Sendable { guard let self = self else { return } do { let transactions = try await self.openIap.getAllTransactionsIOS() - let transactionDicts = transactions.map { self.purchaseIOSToDictionary($0) } + let transactionDicts = try transactions.map { + try GodotIapHelper.encodeRequired($0) + } if let jsonData = try? JSONSerialization.data(withJSONObject: transactionDicts), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -783,7 +818,9 @@ public class GodotIap: RefCounted, @unchecked Sendable { guard let self = self else { return } do { let purchases = try await self.openIap.showManageSubscriptionsIOS() - let purchaseDicts = purchases.map { self.purchaseIOSToDictionary($0) } + let purchaseDicts = try purchases.map { + try GodotIapHelper.encodeRequired($0) + } if let jsonData = try? JSONSerialization.data(withJSONObject: purchaseDicts), let jsonString = String(data: jsonData, encoding: .utf8) { @@ -1625,13 +1662,15 @@ public class GodotIap: RefCounted, @unchecked Sendable { private func emitAsyncFailure( method: String, requestId: String, - message: String + message: String, + code: String = ErrorCode.serviceError.rawValue ) { let dict = asyncResultDictionary( method: method, requestId: requestId, success: false ) + dict["code"] = Variant(code) dict["error"] = Variant(message) self.productsFetched.emit(dict) } diff --git a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift index 838d8a9b8..2029d39ef 100644 --- a/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift +++ b/libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift @@ -64,6 +64,33 @@ enum GodotIapHelper { array } + // Preserve compatibility with the currently published native OpenIAP + // package while making authoritative query serialization all-or-nothing. + // Its non-throwing helpers return an empty dictionary on encoding failure. + static func encodeRequired(_ value: T) throws -> [String: Any] { + let encoded = OpenIapSerialization.encode(value) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + return encoded + } + + static func purchasesRequired(_ purchases: [Purchase]) throws -> [[String: Any]] { + try purchases.map { purchase in + let encoded = OpenIapSerialization.purchase(purchase) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native purchase payload" + ) + } + return encoded + } + } + // MARK: - Parsing /// Parse an OpenIAP product query type without silently changing an diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt index e91d53f1b..bdc979c40 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/InAppPurchaseIOS.kt @@ -456,11 +456,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.showManageSubscriptionsIOSWithCompletion { result, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - } else if (result != null) { - val purchases = convertAnyListToPurchaseIOSList(result) - continuation.resumeIfActive(purchases) } else { - continuation.resumeIfActive(emptyList()) + runCatching { convertAnyListToPurchaseIOSList(result) } + .fold( + onSuccess = continuation::resumeIfActive, + onFailure = continuation::resumeWithExceptionIfActive, + ) } } } @@ -546,11 +547,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.getAvailablePurchasesWithCompletion { result, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - } else if (result != null) { - val purchases = convertAnyListToPurchases(result) - continuation.resumeIfActive(purchases) } else { - continuation.resumeIfActive(emptyList()) + runCatching { convertAnyListToPurchases(result) } + .fold( + onSuccess = continuation::resumeIfActive, + onFailure = continuation::resumeWithExceptionIfActive, + ) } } } @@ -585,11 +587,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.getPendingTransactionsIOSWithCompletion { result, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - } else if (result != null) { - val purchases = convertAnyListToPurchaseIOSList(result) - continuation.resumeIfActive(purchases) } else { - continuation.resumeIfActive(emptyList()) + runCatching { convertAnyListToPurchaseIOSList(result) } + .fold( + onSuccess = continuation::resumeIfActive, + onFailure = continuation::resumeWithExceptionIfActive, + ) } } } @@ -604,11 +607,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.getAllTransactionsIOSWithCompletion { result, error -> if (error != null) { continuation.resumeWithExceptionIfActive(error.toPurchaseException()) - } else if (result != null) { - val purchases = convertAnyListToPurchaseIOSList(result) - continuation.resumeIfActive(purchases) } else { - continuation.resumeIfActive(emptyList()) + runCatching { convertAnyListToPurchaseIOSList(result) } + .fold( + onSuccess = continuation::resumeIfActive, + onFailure = continuation::resumeWithExceptionIfActive, + ) } } } @@ -659,7 +663,13 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return@getActiveSubscriptionsWithCompletion } - continuation.resumeIfActive(filterActiveSubscriptions(result, subscriptionIds)) + try { + continuation.resumeIfActive( + decodeActiveSubscriptionListPayloadIOS(result, subscriptionIds) + ) + } catch (decodeError: Exception) { + continuation.resumeWithExceptionIfActive(decodeError) + } } } @@ -751,31 +761,16 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return@getActiveSubscriptionsWithCompletion } - continuation.resumeIfActive(filterActiveSubscriptions(result, subscriptionIds).isNotEmpty()) - } - } - - private fun filterActiveSubscriptions( - result: List<*>?, - subscriptionIds: List? - ): List { - val subscriptions = result?.mapNotNull { item -> - val map = (item as? Map<*, *>)?.mapKeys { it.key.toString() } ?: return@mapNotNull null - try { - ActiveSubscription.fromJson(map) - } catch (e: Exception) { - null + try { + continuation.resumeIfActive( + decodeActiveSubscriptionListPayloadIOS(result, subscriptionIds).isNotEmpty() + ) + } catch (decodeError: Exception) { + continuation.resumeWithExceptionIfActive(decodeError) + } } - } ?: emptyList() - - if (subscriptionIds.isNullOrEmpty()) { - return subscriptions } - val filter = subscriptionIds.toSet() - return subscriptions.filter { it.productId in filter } - } - /** * Check intro-offer eligibility for a subscription group. * @@ -1179,28 +1174,12 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { @Suppress("UNCHECKED_CAST") private fun convertAnyListToPurchases(data: Any?): List { - if (data == null) return emptyList() - - return try { - val list = data as? List<*> ?: return emptyList() - list.mapNotNull { convertAnyToPurchase(it) } - } catch (e: Exception) { - emptyList() - } + return decodePurchaseListPayloadIOS(data) } @Suppress("UNCHECKED_CAST") private fun convertAnyListToPurchaseIOSList(data: Any?): List { - if (data == null) return emptyList() - - return try { - val list = data as? List<*> ?: return emptyList() - list.mapNotNull { item -> - convertAnyToPurchaseIOS(item) - } - } catch (e: Exception) { - emptyList() - } + return decodePurchaseListPayloadIOS(data) } @Suppress("UNCHECKED_CAST") diff --git a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt index fe63ea8f5..308443021 100644 --- a/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt +++ b/libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt @@ -1,8 +1,12 @@ package io.github.hyochan.kmpiap import io.github.hyochan.kmpiap.openiap.AdvancedCommerceInfoIOS +import io.github.hyochan.kmpiap.openiap.ActiveSubscription +import io.github.hyochan.kmpiap.openiap.ErrorCode +import io.github.hyochan.kmpiap.openiap.PurchaseError import io.github.hyochan.kmpiap.openiap.PurchaseIOS import io.github.hyochan.kmpiap.openiap.PurchaseOfferIOS +import io.github.hyochan.kmpiap.openiap.PurchaseState import io.github.hyochan.kmpiap.openiap.RenewalInfoIOS import io.github.hyochan.kmpiap.openiap.TransactionCommitmentInfoIOS import platform.Foundation.NSNull @@ -92,6 +96,136 @@ internal fun decodePurchasePayloadIOS(data: Any?): PurchaseIOS? { return runCatching { PurchaseIOS.fromJson(fallback) }.getOrNull() } +/** Decode an authoritative native purchase list without partial success. */ +internal fun decodePurchaseListPayloadIOS(data: Any?): List { + val list = data as? List<*> ?: throw malformedPurchaseListIOS( + "Native bridge returned a non-list purchase payload" + ) + return list.mapIndexed { index, item -> + val bridgeMap = normalizeBridgeMap(item) ?: throw malformedPurchaseListIOS( + "Native bridge returned a malformed purchase at index $index" + ) + if (!bridgeMap.hasNativePurchaseQuantityIOS()) { + throw malformedPurchaseListIOS( + "Native bridge returned a purchase without quantity at index $index" + ) + } + if (!bridgeMap.hasNativePurchaseIdentityIOS()) { + throw malformedPurchaseListIOS( + "Native bridge returned a purchase without required identity at index $index" + ) + } + val normalized = normalizePurchasePayloadIOS(bridgeMap)!! + if ( + normalized["store"] != "apple" || + (normalized["productId"] as? String).isNullOrBlank() || + (normalized["id"] as? String).isNullOrBlank() || + (normalized["transactionId"] as? String).isNullOrBlank() || + normalized["isAutoRenewing"] !is Boolean || + !normalized.hasValidPurchaseStateIOS() || + !normalized.hasValidPurchaseQuantityIOS() || + !normalized.hasValidTransactionDateIOS() || + !normalized.hasValidPurchaseIdsIOS() || + !normalized.hasValidOptionalPurchaseObjectsIOS() + ) { + throw malformedPurchaseListIOS( + "Native bridge returned a purchase with malformed required fields at index $index" + ) + } + runCatching { PurchaseIOS.fromJson(normalized) }.getOrElse { + throw malformedPurchaseListIOS( + "Failed to decode native purchase at index $index" + ) + } + } +} + +/** Decode an authoritative native active-subscription list without partial success. */ +internal fun decodeActiveSubscriptionListPayloadIOS( + data: Any?, + subscriptionIds: List? = null, +): List { + val list = data as? List<*> ?: throw malformedPurchaseListIOS( + "Native bridge returned a non-list active-subscription payload" + ) + val subscriptions = list.mapIndexed { index, item -> + val normalized = normalizeBridgeMap(item) ?: throw malformedPurchaseListIOS( + "Native bridge returned a malformed active subscription at index $index" + ) + val transactionDate = normalized["transactionDate"] as? Number + val renewalInfo = normalized["renewalInfoIOS"] + if ( + (normalized["productId"] as? String).isNullOrBlank() || + (normalized["transactionId"] as? String).isNullOrBlank() || + normalized["isActive"] !is Boolean || + transactionDate == null || + !transactionDate.toDouble().isFinite() || + renewalInfo != null && renewalInfo !is Map<*, *> + ) { + throw malformedPurchaseListIOS( + "Native bridge returned an active subscription with malformed required fields at index $index" + ) + } + runCatching { ActiveSubscription.fromJson(normalized) }.getOrElse { + throw malformedPurchaseListIOS( + "Failed to decode native active subscription at index $index" + ) + } + } + + if (subscriptionIds.isNullOrEmpty()) return subscriptions + val filter = subscriptionIds.toSet() + return subscriptions.filter { it.productId in filter } +} + +private fun Map.hasNativePurchaseQuantityIOS(): Boolean = + this["quantity"] is Number || this["quantityIOS"] is Number + +private fun Map.hasNativePurchaseIdentityIOS(): Boolean = + (this["store"] as? String)?.equals("apple", ignoreCase = true) == true && + !(this["productId"] as? String).isNullOrBlank() && + !(this["id"] as? String).isNullOrBlank() && + !(this["transactionId"] as? String).isNullOrBlank() + +private fun Map.hasValidPurchaseStateIOS(): Boolean { + val raw = this["purchaseState"] as? String ?: return false + return runCatching { PurchaseState.fromJson(raw) }.isSuccess +} + +private fun Map.hasValidPurchaseQuantityIOS(): Boolean { + val value = this["quantity"] as? Number ?: return false + val doubleValue = value.toDouble() + return doubleValue.isFinite() && doubleValue == value.toInt().toDouble() +} + +private fun Map.hasValidTransactionDateIOS(): Boolean { + val value = this["transactionDate"] as? Number ?: return false + return value.toDouble().isFinite() +} + +private fun Map.hasValidPurchaseIdsIOS(): Boolean { + val value = this["ids"] ?: return true + return value is List<*> && value.all { it is String } +} + +private fun Map.hasValidOptionalPurchaseObjectsIOS(): Boolean = listOf( + "advancedCommerceInfoIOS", + "commitmentInfoIOS", + "offerIOS", + "renewalInfoIOS", +).all { key -> + val value = this[key] + value == null || value is Map<*, *> +} + +private fun malformedPurchaseListIOS(message: String): PurchaseException = + PurchaseException( + PurchaseError( + code = ErrorCode.BillingResponseJsonParseError, + message = message, + ) + ) + private fun MutableMap.removeMalformedPurchaseObjectIOS( key: String, decode: (Map) -> Any, diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt index 2d82fd626..160cccf3d 100644 --- a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerTestIOS.kt @@ -1,13 +1,126 @@ package io.github.hyochan.kmpiap +import io.github.hyochan.kmpiap.openiap.ErrorCode import io.github.hyochan.kmpiap.openiap.ProductSubscriptionIOS import io.github.hyochan.kmpiap.openiap.SubscriptionBillingPlanTypeIOS import platform.Foundation.NSNull import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertNotNull class ProductPayloadNormalizerTestIOS { + private fun validPurchase(id: String = "transaction-1"): Map = mapOf( + "store" to "apple", + "id" to id, + "productId" to "premium.monthly", + "purchaseState" to "purchased", + "quantity" to 1, + "transactionDate" to 1_700_000_000_000.0, + "transactionId" to id, + "isAutoRenewing" to true, + ) + + private fun validActiveSubscription(id: String = "transaction-1"): Map = mapOf( + "productId" to "premium.monthly", + "isActive" to true, + "transactionId" to id, + "transactionDate" to 1_700_000_000_000.0, + ) + + @Test + fun `strict purchase list preserves explicit empty result`() { + assertEquals(emptyList(), decodePurchaseListPayloadIOS(emptyList())) + } + + @Test + fun `strict purchase list rejects non-list and mixed malformed payloads`() { + val nonList = assertFailsWith { + decodePurchaseListPayloadIOS(null) + } + assertEquals(ErrorCode.BillingResponseJsonParseError, nonList.error.code) + + val mixed = assertFailsWith { + decodePurchaseListPayloadIOS(listOf(validPurchase(), "invalid")) + } + assertEquals(ErrorCode.BillingResponseJsonParseError, mixed.error.code) + } + + @Test + fun `strict purchase list rejects malformed optional objects`() { + val malformed = validPurchase().toMutableMap().apply { + this["advancedCommerceInfoIOS"] = mapOf( + "items" to listOf("not-an-object") + ) + } + + val error = assertFailsWith { + decodePurchaseListPayloadIOS(listOf(malformed)) + } + assertEquals(ErrorCode.BillingResponseJsonParseError, error.error.code) + } + + @Test + fun `strict active subscription list rejects partial and malformed payloads`() { + assertEquals( + emptyList(), + decodeActiveSubscriptionListPayloadIOS(emptyList()), + ) + + val mixed = assertFailsWith { + decodeActiveSubscriptionListPayloadIOS( + listOf(validActiveSubscription(), mapOf("productId" to "broken")) + ) + } + assertEquals(ErrorCode.BillingResponseJsonParseError, mixed.error.code) + + val invalidRenewalInfo = assertFailsWith { + decodeActiveSubscriptionListPayloadIOS( + listOf(validActiveSubscription() + ("renewalInfoIOS" to "invalid")) + ) + } + assertEquals( + ErrorCode.BillingResponseJsonParseError, + invalidRenewalInfo.error.code, + ) + } + + @Test + fun `strict active subscription list filters only after complete decoding`() { + val other = validActiveSubscription("transaction-2") + + ("productId" to "premium.yearly") + val result = decodeActiveSubscriptionListPayloadIOS( + listOf(validActiveSubscription(), other), + listOf("premium.yearly"), + ) + + assertEquals(listOf("premium.yearly"), result.map { it.productId }) + } + + @Test + fun `strict purchase list rejects generated decoder defaults and lossy arrays`() { + val malformedPayloads = listOf( + validPurchase().minus("store"), + validPurchase().minus("id"), + validPurchase().minus("transactionId"), + validPurchase().minus("productId"), + validPurchase().minus("isAutoRenewing"), + validPurchase().minus("purchaseState"), + validPurchase().minus("transactionDate"), + validPurchase().minus("quantity"), + validPurchase().plus("quantity" to 1.5), + validPurchase().plus("ids" to listOf("transaction-1", 2)), + validPurchase().plus("offerIOS" to "not-an-object"), + ) + + malformedPayloads.forEach { payload -> + val error = assertFailsWith { + decodePurchaseListPayloadIOS(listOf(payload)) + } + assertEquals(ErrorCode.BillingResponseJsonParseError, error.error.code) + } + } + @Test fun `recovers native offers from an empty canonical placeholder`() { val payload: Map = mapOf( diff --git a/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs b/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs new file mode 100644 index 000000000..5f92cac8d --- /dev/null +++ b/libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs @@ -0,0 +1,98 @@ +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using OpenIap; + +namespace OpenIap.Maui; + +/// Strict decoders for authoritative native bridge results. +internal static class BridgePayloadDecoder +{ + internal static List DecodeRequiredItems(string json, string operation) + { + if (string.IsNullOrWhiteSpace(json)) + throw Malformed(operation, "returned an empty envelope"); + + JsonNode? node; + try { node = JsonNode.Parse(json); } + catch (JsonException) { throw Malformed(operation, "returned invalid JSON"); } + + if (node is not JsonObject envelope || envelope["items"] is not JsonArray items) + throw Malformed(operation, "returned an envelope without an items array"); + + return DecodeRequiredArray(items, operation); + } + + internal static List DecodeRequiredArray(JsonArray? array, string operation) + { + if (array is null) + throw Malformed(operation, "returned no array payload"); + + var result = new List(array.Count); + for (var index = 0; index < array.Count; index++) + { + var item = array[index]; + if (item is null) + throw Malformed(operation, $"returned a null item at index {index}"); + + try + { + var decoded = item.Deserialize(JsonOptions.Default); + if (decoded is null) + throw Malformed(operation, $"returned an undecodable item at index {index}"); + ValidatePurchase(decoded, operation, index); + ValidateActiveSubscription(decoded, operation, index); + result.Add(decoded); + } + catch (OpenIapException) { throw; } + catch (Exception) when (item is not null) + { + throw Malformed(operation, $"returned a malformed item at index {index}"); + } + } + return result; + } + + private static void ValidateActiveSubscription(T decoded, string operation, int index) + { + if (decoded is not ActiveSubscription subscription) + return; + + if (string.IsNullOrWhiteSpace(subscription.ProductId) || + string.IsNullOrWhiteSpace(subscription.TransactionId) || + !double.IsFinite(subscription.TransactionDate)) + { + throw Malformed(operation, $"returned an active subscription with invalid identity at index {index}"); + } + } + + private static void ValidatePurchase(T decoded, string operation, int index) + { + if (decoded is not PurchaseCommon purchase) + return; + + if (string.IsNullOrWhiteSpace(purchase.Id) || + string.IsNullOrWhiteSpace(purchase.ProductId) || + !double.IsFinite(purchase.TransactionDate) || + purchase.Ids?.Any(string.IsNullOrWhiteSpace) == true || + decoded is PurchaseIOS ios && + (string.IsNullOrWhiteSpace(ios.TransactionId) || + ios.Store is not IapStore.Apple) || + decoded is PurchaseAndroid android && + android.Store != IapStore.Google && + android.Store != IapStore.Amazon && + android.Store != IapStore.Horizon) + { + throw Malformed(operation, $"returned a purchase with invalid identity at index {index}"); + } + } + + private static OpenIapException Malformed(string operation, string reason) + => OpenIapErrorMapper.Wrap( + ErrorCode.BillingResponseJsonParseError, + $"{operation} {reason}"); +} diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs index 933caee3b..abeaf09fb 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/Android/OpenIapAndroid.Resolvers.cs @@ -196,7 +196,9 @@ public async Task> GetAvailablePurchasesAsync(PurchaseOp { var json = options is null ? null : JsonSerializer.Serialize(options, JsonOptions.Default); var result = await Invoke(cb => _module.GetAvailablePurchases(json, cb)); - return DecodeItems(result); + return BridgePayloadDecoder.DecodeRequiredItems( + result, + "getAvailablePurchases"); } public async Task> GetActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null) diff --git a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs index f665399b9..793a2ff13 100644 --- a/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs +++ b/libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs @@ -399,7 +399,7 @@ public Task ShowExternalPurchaseCusto return InvokeDict(cb => _module.ShowExternalPurchaseCustomLinkNoticeIOS(noticeType.ToJson(), cb), required: true)!; } - public Task> ShowManageSubscriptionsIOSAsync() => InvokeArray(cb => _module.ShowManageSubscriptionsIOS(cb)); + public Task> ShowManageSubscriptionsIOSAsync() => InvokeArray(cb => _module.ShowManageSubscriptionsIOS(cb), required: true, operation: "showManageSubscriptionsIOS"); public Task SyncIOSAsync() => InvokeBool(cb => _module.SyncIOS(cb)); @@ -469,11 +469,14 @@ public Task FetchProductsAsync(ProductRequest @params) } public Task> GetAvailablePurchasesAsync(PurchaseOptions? options = null) - => InvokeArray(cb => _module.GetAvailablePurchasesWithOptions(ToPurchaseOptionsDictionary(options), cb)); + => InvokeArray(cb => _module.GetAvailablePurchasesWithOptions(ToPurchaseOptionsDictionary(options), cb), required: true, operation: "getAvailablePurchases"); public async Task> GetActiveSubscriptionsAsync(IReadOnlyList? subscriptionIds = null) { - var result = await InvokeArray(cb => _module.GetActiveSubscriptions(cb)); + var result = await InvokeArray( + cb => _module.GetActiveSubscriptions(cb), + required: true, + operation: "getActiveSubscriptions"); if (subscriptionIds is null || subscriptionIds.Count == 0) return result; var filter = new HashSet(subscriptionIds); return result.Where(a => filter.Contains(a.ProductId)).ToList(); @@ -505,7 +508,7 @@ public Task CanPresentExternalPurchaseNoticeIOSAsync() } public Task CurrentEntitlementIOSAsync(string sku) => InvokeDict(cb => _module.CurrentEntitlementIOS(sku, cb)); - public Task> GetAllTransactionsIOSAsync() => InvokeArray(cb => _module.GetAllTransactionsIOS(cb)); + public Task> GetAllTransactionsIOSAsync() => InvokeArray(cb => _module.GetAllTransactionsIOS(cb), required: true, operation: "getAllTransactionsIOS"); public Task GetAppTransactionIOSAsync() { @@ -521,7 +524,7 @@ public Task GetExternalPurchaseCustomL return InvokeDict(cb => _module.GetExternalPurchaseCustomLinkTokenIOS(tokenType.ToJson(), cb), required: true)!; } - public Task> GetPendingTransactionsIOSAsync() => InvokeArray(cb => _module.GetPendingTransactionsIOS(cb)); + public Task> GetPendingTransactionsIOSAsync() => InvokeArray(cb => _module.GetPendingTransactionsIOS(cb), required: true, operation: "getPendingTransactionsIOS"); public Task GetPromotedProductIOSAsync() => InvokeDict(cb => _module.GetPromotedProductIOS(cb)); public Task GetReceiptDataIOSAsync() => InvokeNullableString(cb => _module.GetReceiptDataIOS(cb)); public Task GetTransactionJwsIOSAsync(string sku) => InvokeNullableString(cb => _module.GetTransactionJwsIOS(sku, cb)); @@ -707,7 +710,10 @@ private Task InvokeBool(Action> dispatch) return tcs.Task; } - private Task> InvokeArray(Action> dispatch) + private Task> InvokeArray( + Action> dispatch, + bool required = false, + string? operation = null) { var tcs = new TaskCompletionSource>(TaskCreationOptions.RunContinuationsAsynchronously); Console.WriteLine($"[OpenIapIOS] InvokeArray<{typeof(T).Name}> dispatch"); @@ -718,7 +724,9 @@ private Task> InvokeArray(Action> Console.WriteLine($"[OpenIapIOS] InvokeArray<{typeof(T).Name}> callback: arr.count={arr?.Count ?? 0}, err={err?.LocalizedDescription ?? "nil"}"); if (err is not null) { tcs.TrySetException(MapNSError(err)); return; } var node = NSObjectJsonBridge.ArrayToArray(arr); - var list = DeserializeArray(node) ?? new List(); + var list = required + ? BridgePayloadDecoder.DecodeRequiredArray(node, operation ?? typeof(T).Name) + : DeserializeArray(node) ?? new List(); Console.WriteLine($"[OpenIapIOS] InvokeArray<{typeof(T).Name}> deserialized {list.Count} items"); tcs.TrySetResult(list); } diff --git a/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs b/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs new file mode 100644 index 000000000..c440174a4 --- /dev/null +++ b/libraries/maui-iap/tests/OpenIap.Maui.Tests/BridgePayloadDecoderTests.cs @@ -0,0 +1,195 @@ +using System.Text.Json.Nodes; +using OpenIap; +using Xunit; + +namespace OpenIap.Maui.Tests; + +public class BridgePayloadDecoderTests +{ + private const string PurchaseJson = """ + { + "__typename": "PurchaseAndroid", + "id": "token-1", + "isAutoRenewing": false, + "productId": "premium", + "purchaseState": "purchased", + "purchaseToken": "token-1", + "quantity": 1, + "store": "google", + "transactionDate": 1720000000000 + } + """; + + private const string PurchaseIosJson = """ + { + "__typename": "PurchaseIOS", + "id": "transaction-1", + "isAutoRenewing": false, + "productId": "premium", + "purchaseState": "purchased", + "quantity": 1, + "store": "apple", + "transactionDate": 1720000000000, + "transactionId": "transaction-1" + } + """; + + private const string ActiveSubscriptionJson = """ + { + "isActive": true, + "productId": "premium.monthly", + "transactionDate": 1720000000000, + "transactionId": "transaction-1" + } + """; + + [Fact] + public void ExplicitEmptyItemsIsAuthoritativeSuccess() + { + var result = BridgePayloadDecoder.DecodeRequiredItems( + """{"items":[]}""", + "getAvailablePurchases"); + + Assert.Empty(result); + } + + [Theory] + [InlineData("")] + [InlineData("{}")] + [InlineData("{\"items\":null}")] + [InlineData("not-json")] + public void MissingOrMalformedEnvelopeFails(string payload) + { + var error = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredItems( + payload, + "getAvailablePurchases")); + + Assert.Equal(ErrorCode.BillingResponseJsonParseError, error.Error.Code); + } + + [Fact] + public void MixedValidAndMalformedItemsFailAtomically() + { + var payload = $$"""{"items":[{{PurchaseJson}},{}]}"""; + + var error = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredItems( + payload, + "getAvailablePurchases")); + + Assert.Equal(ErrorCode.BillingResponseJsonParseError, error.Error.Code); + } + + [Theory] + [InlineData("id")] + [InlineData("productId")] + public void EmptyPurchaseIdentityFails(string field) + { + var malformed = PurchaseJson.Replace($"\"{field}\": \"{(field == "id" ? "token-1" : "premium")}\"", $"\"{field}\": \"\""); + var payload = $$"""{"items":[{{malformed}}]}"""; + + var error = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredItems( + payload, + "getAvailablePurchases")); + + Assert.Equal(ErrorCode.BillingResponseJsonParseError, error.Error.Code); + } + + [Theory] + [InlineData("\"id\": \"token-1\",")] + [InlineData("\"isAutoRenewing\": false,")] + [InlineData("\"productId\": \"premium\",")] + [InlineData("\"purchaseState\": \"purchased\",")] + [InlineData("\"quantity\": 1,")] + [InlineData("\"store\": \"google\",")] + [InlineData("\"transactionDate\": 1720000000000")] + public void MissingRequiredPurchaseFieldFails(string propertyLine) + { + var malformed = PurchaseJson.Replace(propertyLine, ""); + var payload = $$"""{"items":[{{malformed}}]}"""; + + var error = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredItems( + payload, + "getAvailablePurchases")); + + Assert.Equal(ErrorCode.BillingResponseJsonParseError, error.Error.Code); + } + + [Fact] + public void ValidPurchaseListDecodes() + { + var payload = $$"""{"items":[{{PurchaseJson}}]}"""; + + var result = BridgePayloadDecoder.DecodeRequiredItems( + payload, + "getAvailablePurchases"); + + Assert.Single(result); + var purchase = Assert.IsType(result[0]); + Assert.Equal("premium", purchase.ProductId); + } + + [Fact] + public void PurchaseRuntimeTypeRejectsForeignStore() + { + var validIos = $$"""{"items":[{{PurchaseIosJson}}]}"""; + var decoded = BridgePayloadDecoder.DecodeRequiredItems( + validIos, + "getAvailablePurchases"); + Assert.IsType(Assert.Single(decoded)); + + var foreignIos = validIos.Replace("\"store\": \"apple\"", "\"store\": \"google\""); + var iosError = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredItems( + foreignIos, + "getAvailablePurchases")); + Assert.Equal(ErrorCode.BillingResponseJsonParseError, iosError.Error.Code); + + var foreignAndroid = $$"""{"items":[{{PurchaseJson.Replace("\"store\": \"google\"", "\"store\": \"apple\"")}}]}"""; + var androidError = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredItems( + foreignAndroid, + "getAvailablePurchases")); + Assert.Equal(ErrorCode.BillingResponseJsonParseError, androidError.Error.Code); + } + + [Fact] + public void ActiveSubscriptionListFailsAtomically() + { + var valid = new JsonArray(JsonNode.Parse(ActiveSubscriptionJson)); + var decoded = BridgePayloadDecoder.DecodeRequiredArray( + valid, + "getActiveSubscriptions"); + Assert.Equal("premium.monthly", Assert.Single(decoded).ProductId); + + var mixed = new JsonArray( + JsonNode.Parse(ActiveSubscriptionJson), + JsonNode.Parse("""{"productId":"broken"}""")); + var error = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredArray( + mixed, + "getActiveSubscriptions")); + Assert.Equal(ErrorCode.BillingResponseJsonParseError, error.Error.Code); + } + + [Theory] + [InlineData("productId")] + [InlineData("transactionId")] + public void ActiveSubscriptionRejectsEmptyIdentity(string field) + { + var value = field == "productId" ? "premium.monthly" : "transaction-1"; + var malformed = ActiveSubscriptionJson.Replace( + $"\"{field}\": \"{value}\"", + $"\"{field}\": \"\""); + var payload = new JsonArray(JsonNode.Parse(malformed)); + + var error = Assert.Throws(() => + BridgePayloadDecoder.DecodeRequiredArray( + payload, + "getActiveSubscriptions")); + Assert.Equal(ErrorCode.BillingResponseJsonParseError, error.Error.Code); + } +} diff --git a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index dd32fc55d..f5e0b025d 100644 --- a/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -512,6 +512,7 @@ class HybridRnIap : HybridRnIapSpec() { return@async defaultResult } + var reachedOpenIapRequest = false try { ensureConnection() @@ -628,6 +629,7 @@ class HybridRnIap : HybridRnIapSpec() { ) val result = withContext(Dispatchers.Main) { + reachedOpenIapRequest = true openIap.requestPurchase(requestProps) } val purchases = result.purchasesOrEmpty() @@ -640,15 +642,19 @@ class HybridRnIap : HybridRnIapSpec() { ) defaultResult + } catch (e: CancellationException) { + throw e } catch (e: Exception) { RnIapLog.failure("requestPurchase", e) - sendPurchaseError( - toErrorResult( - error = OpenIapError.PurchaseFailed(), - debugMessage = e.message, - messageOverride = e.message + if (!reachedOpenIapRequest) { + sendPurchaseError( + toErrorResult( + error = OpenIapError.PurchaseFailed(), + debugMessage = e.message, + messageOverride = e.message + ) ) - ) + } defaultResult } } diff --git a/libraries/react-native-iap/ios/HybridRnIap.swift b/libraries/react-native-iap/ios/HybridRnIap.swift index 2343524e6..48601d873 100644 --- a/libraries/react-native-iap/ios/HybridRnIap.swift +++ b/libraries/react-native-iap/ios/HybridRnIap.swift @@ -311,7 +311,7 @@ class HybridRnIap: HybridRnIapSpec { let purchaseOptions = try OpenIapSerialization.purchaseOptions(from: optionsDictionary) RnIapLog.payload("getAvailablePurchases", optionsDictionary) let purchases = try await OpenIapModule.shared.getAvailablePurchases(purchaseOptions) - let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(purchases)) + let payloads = RnIapHelper.sanitizeArray(try RnIapHelper.purchasesRequired(purchases)) RnIapLog.result("getAvailablePurchases", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } } catch let purchaseError as PurchaseError { @@ -331,7 +331,9 @@ class HybridRnIap: HybridRnIapSpec { RnIapLog.payload("getActiveSubscriptions", subscriptionIds ?? []) // Call OpenIAP's native getActiveSubscriptions - includes renewalInfoIOS! let subscriptions = try await OpenIapModule.shared.getActiveSubscriptions(subscriptionIds) - let payloads = RnIapHelper.sanitizeArray(subscriptions.map { OpenIapSerialization.encode($0) }) + let payloads = RnIapHelper.sanitizeArray( + try subscriptions.map { try RnIapHelper.encodeRequired($0) } + ) RnIapLog.result("getActiveSubscriptions", payloads) return payloads.map { RnIapHelper.convertActiveSubscriptionDictionary($0) } } catch let purchaseError as PurchaseError { @@ -761,12 +763,15 @@ class HybridRnIap: HybridRnIapSpec { } } } - let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(unionPurchases)) + let payloads = RnIapHelper.sanitizeArray(try RnIapHelper.purchasesRequired(unionPurchases)) RnIapLog.result("getPendingTransactionsIOS", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } + } catch let purchaseError as PurchaseError { + RnIapLog.failure("getPendingTransactionsIOS", error: purchaseError) + throw OpenIapException.from(purchaseError) } catch { RnIapLog.failure("getPendingTransactionsIOS", error: error) - return [] + throw OpenIapException.make(code: .serviceError, message: error.localizedDescription) } } } @@ -792,7 +797,7 @@ class HybridRnIap: HybridRnIapSpec { self.purchasePayloadById[key] = value } } - let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(unionPurchases)) + let payloads = RnIapHelper.sanitizeArray(try RnIapHelper.purchasesRequired(unionPurchases)) RnIapLog.result("getAllTransactionsIOS", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } } catch let purchaseError as PurchaseError { @@ -829,7 +834,7 @@ class HybridRnIap: HybridRnIapSpec { RnIapLog.payload("showManageSubscriptionsIOS", nil) let changedPurchases = try await OpenIapModule.shared.showManageSubscriptionsIOS() let unionPurchases = changedPurchases.map { OpenIAP.Purchase.purchaseIos($0) } - let payloads = RnIapHelper.sanitizeArray(OpenIapSerialization.purchases(unionPurchases)) + let payloads = RnIapHelper.sanitizeArray(try RnIapHelper.purchasesRequired(unionPurchases)) RnIapLog.result("showManageSubscriptionsIOS", payloads) return payloads.map { RnIapHelper.convertPurchaseDictionary($0) } } catch let purchaseError as PurchaseError { diff --git a/libraries/react-native-iap/ios/RnIapHelper.swift b/libraries/react-native-iap/ios/RnIapHelper.swift index 6eff7c1b9..c2832ce5f 100644 --- a/libraries/react-native-iap/ios/RnIapHelper.swift +++ b/libraries/react-native-iap/ios/RnIapHelper.swift @@ -65,6 +65,36 @@ enum RnIapHelper { array.map { sanitizeDictionary($0) } } + // The currently published native OpenIAP package reports an encoding + // failure as an empty dictionary. Reject that sentinel so a partial batch + // can never be surfaced as a successful query. + static func encodeRequired(_ value: T) throws -> [String: Any] { + let encoded = OpenIapSerialization.encode(value) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + return encoded + } + + // The currently published native OpenIAP package reports an encoding + // failure as an empty dictionary. Reject that sentinel so a partial batch + // can never be surfaced as a successful purchase query. + static func purchasesRequired(_ purchases: [OpenIAP.Purchase]) throws -> [[String: Any]] { + try purchases.map { purchase in + let encoded = OpenIapSerialization.purchase(purchase) + guard !encoded.isEmpty else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native purchase payload" + ) + } + return encoded + } + } + // MARK: - Variant wrapper helpers static func wrapString(_ value: String?) -> Variant_NullType_String? { diff --git a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts index 3fd1da2c1..ec9b3a5cf 100644 --- a/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts +++ b/libraries/react-native-iap/src/__tests__/hooks/useIAP.test.ts @@ -65,9 +65,7 @@ describe('hooks/useIAP (renderer)', () => { mockFetchProducts = jest .spyOn(IAP, 'fetchProducts') .mockResolvedValue([] as any); - mockSyncIOS = jest - .spyOn(IAP, 'syncIOS') - .mockResolvedValue(undefined as any); + mockSyncIOS = jest.spyOn(IAP, 'syncIOS').mockResolvedValue(true as any); jest.spyOn(IAP, 'purchaseUpdatedListener').mockImplementation((cb: any) => { capturedPurchaseListener = cb; return {remove: jest.fn()}; @@ -277,11 +275,18 @@ describe('hooks/useIAP (renderer)', () => { }); await act(async () => {}); + let thrown: unknown; await act(async () => { - await api.getAvailablePurchases(); + try { + await api.getAvailablePurchases(); + } catch (error) { + thrown = error; + } }); expect(onError).toHaveBeenCalledWith(purchaseError); + expect(onError).toHaveBeenCalledTimes(1); + expect(thrown).toBe(purchaseError); }); it('calls onError when getActiveSubscriptions fails', async () => { @@ -300,13 +305,17 @@ describe('hooks/useIAP (renderer)', () => { }); await act(async () => {}); + let thrown: unknown; await act(async () => { - const result = await api.getActiveSubscriptions(); - // Should return empty array on error - expect(result).toEqual([]); + try { + await api.getActiveSubscriptions(); + } catch (error) { + thrown = error; + } }); expect(onError).toHaveBeenCalledWith(subscriptionError); + expect(thrown).toBe(subscriptionError); }); it('calls onError when hasActiveSubscriptions fails', async () => { @@ -325,13 +334,17 @@ describe('hooks/useIAP (renderer)', () => { }); await act(async () => {}); + let thrown: unknown; await act(async () => { - const result = await api.hasActiveSubscriptions(); - // Should return false on error - expect(result).toBe(false); + try { + await api.hasActiveSubscriptions(); + } catch (error) { + thrown = error; + } }); expect(onError).toHaveBeenCalledWith(hasSubsError); + expect(thrown).toBe(hasSubsError); }); it('calls onError when restorePurchases fails (syncIOS error on iOS)', async () => { @@ -350,12 +363,52 @@ describe('hooks/useIAP (renderer)', () => { }); await act(async () => {}); + let thrown: unknown; await act(async () => { - await api.restorePurchases(); + try { + await api.restorePurchases(); + } catch (error) { + thrown = error; + } }); expect(mockSyncIOS).toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith(restoreError); + expect(onError).toHaveBeenCalledTimes(1); + expect(thrown).toBe(restoreError); + }); + + it('rejects when restorePurchases syncIOS returns false on iOS', async () => { + mockSyncIOS.mockResolvedValueOnce(false as any); + + let api: any; + const onError = jest.fn(); + const Harness = () => { + api = useIAP({onError}); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + let thrown: unknown; + await act(async () => { + try { + await api.restorePurchases(); + } catch (error) { + thrown = error; + } + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith(thrown); + expect(thrown).toMatchObject({ + code: IAP.ErrorCode.SyncError, + message: 'App Store purchase sync did not complete', + }); + expect(mockGetAvailablePurchases).not.toHaveBeenCalled(); }); it('calls onError when restorePurchases fails (getAvailablePurchases error)', async () => { @@ -374,11 +427,18 @@ describe('hooks/useIAP (renderer)', () => { }); await act(async () => {}); + let thrown: unknown; await act(async () => { - await api.restorePurchases(); + try { + await api.restorePurchases(); + } catch (error) { + thrown = error; + } }); expect(onError).toHaveBeenCalledWith(purchaseError); + expect(onError).toHaveBeenCalledTimes(1); + expect(thrown).toBe(purchaseError); }); it('restorePurchases calls syncIOS then getAvailablePurchases on iOS', async () => { diff --git a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts index 9991f5f3c..32b500400 100644 --- a/libraries/react-native-iap/src/__tests__/index.kepler.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.kepler.test.ts @@ -1,5 +1,6 @@ import { fetchProducts, + getAvailablePurchases, openRedeemOfferCodeAndroid, requestPurchase, } from '../index.kepler'; @@ -14,12 +15,14 @@ jest.mock('../vega', () => ({ describe('Amazon Vega public API', () => { const fetchProductsNative = jest.fn().mockResolvedValue([]); const requestPurchaseNative = jest.fn().mockResolvedValue([]); + const getAvailablePurchasesNative = jest.fn().mockResolvedValue([]); beforeEach(() => { jest.clearAllMocks(); (getVegaIapModule as jest.Mock).mockReturnValue({ fetchProducts: fetchProductsNative, requestPurchase: requestPurchaseNative, + getAvailablePurchases: getAvailablePurchasesNative, }); }); @@ -52,4 +55,65 @@ describe('Amazon Vega public API', () => { google: {skus: ['coins']}, }); }); + + it('rejects a malformed purchase result instead of returning partial success', async () => { + requestPurchaseNative.mockResolvedValueOnce([ + { + id: 'valid', + productId: 'coins', + transactionDate: Date.now(), + store: 'amazon', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + {id: 'malformed'}, + ]); + const request: RequestPurchaseProps = { + request: {google: {skus: ['coins']}}, + type: 'in-app', + }; + + await expect(requestPurchase(request)).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('rejects a mixed malformed available-purchase list', async () => { + getAvailablePurchasesNative.mockResolvedValueOnce([ + { + id: 'valid', + productId: 'premium', + transactionDate: Date.now(), + store: 'amazon', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + {id: 'malformed'}, + ]); + + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('rejects an Apple purchase returned by the Vega bridge', async () => { + getAvailablePurchasesNative.mockResolvedValueOnce([ + { + id: 'foreign', + transactionId: 'foreign', + productId: 'premium', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + ]); + + await expect(getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); }); diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index 00c326ef4..973dda824 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -237,8 +237,8 @@ describe('Public API (src/index.ts)', () => { }); expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(2); - const duplicateNativeHandler = mockIap.addPurchaseUpdatedListener.mock - .calls[1][0]; + const duplicateNativeHandler = + mockIap.addPurchaseUpdatedListener.mock.calls[1][0]; const nitroPurchase = { id: 't1', transactionId: 't1', @@ -1114,6 +1114,84 @@ describe('Public API (src/index.ts)', () => { expect(res.map((p: any) => p.productId).sort()).toEqual(['p1', 's1']); }); + it('rejects a mixed valid and malformed native purchase list', async () => { + (Platform as any).OS = 'android'; + const valid = { + id: 'transaction-valid', + productId: 'valid', + transactionDate: Date.now(), + store: 'google', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + mockIap.getAvailablePurchases + .mockResolvedValueOnce([valid]) + .mockResolvedValueOnce([{id: 'malformed'}]); + + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('rejects a non-array native purchase payload', async () => { + (Platform as any).OS = 'ios'; + mockIap.getAvailablePurchases.mockResolvedValueOnce(null as any); + + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('preserves an authoritative empty native purchase list', async () => { + (Platform as any).OS = 'ios'; + mockIap.getAvailablePurchases.mockResolvedValueOnce([]); + + await expect(IAP.getAvailablePurchases()).resolves.toEqual([]); + }); + + it('rejects a foreign store in an iOS available-purchase list', async () => { + (Platform as any).OS = 'ios'; + mockIap.getAvailablePurchases.mockResolvedValueOnce([ + { + id: 'foreign', + transactionId: 'foreign', + productId: 'premium', + transactionDate: Date.now(), + store: 'google', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }, + ]); + + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + + it('rejects a foreign store in an Android available-purchase list', async () => { + (Platform as any).OS = 'android'; + mockIap.getAvailablePurchases + .mockResolvedValueOnce([ + { + id: 'foreign', + productId: 'premium', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + transactionId: 'foreign', + }, + ]) + .mockResolvedValueOnce([]); + + await expect(IAP.getAvailablePurchases()).rejects.toMatchObject({ + code: 'billing-response-json-parse-error', + }); + }); + it('Vega path queries purchase updates once', async () => { jest.resetModules(); jest.doMock('react-native', () => ({ @@ -1339,6 +1417,35 @@ describe('Public API (src/index.ts)', () => { expect(res[0].currentPlanId).toBe('premium_monthly'); }); + it.each([ + ['getPendingTransactionsIOS', 'getPendingTransactionsIOS'], + ['getAllTransactionsIOS', 'getAllTransactionsIOS'], + ['showManageSubscriptionsIOS', 'showManageSubscriptionsIOS'], + ])( + '%s rejects a mixed non-Apple batch atomically', + async (apiName, nativeName) => { + (Platform as any).OS = 'ios'; + const valid = { + id: 'apple-transaction', + transactionId: 'apple-transaction', + productId: 'premium', + transactionDate: Date.now(), + store: 'apple', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + mockIap[nativeName] = jest.fn(async () => [ + valid, + {...valid, id: 'foreign', store: 'google'}, + ]); + + await expect(IAP[apiName]()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }, + ); + it('showManageSubscriptionsIOS returns [] on non‑iOS', async () => { (Platform as any).OS = 'android'; await expect(IAP.showManageSubscriptionsIOS()).resolves.toEqual([]); @@ -1491,6 +1598,17 @@ describe('Public API (src/index.ts)', () => { await IAP.restorePurchases(); expect(mockIap.syncIOS).toHaveBeenCalled(); }); + + it('restorePurchases on iOS rejects when syncIOS returns false', async () => { + (Platform as any).OS = 'ios'; + mockIap.syncIOS = jest.fn(async () => false); + + await expect(IAP.restorePurchases()).rejects.toMatchObject({ + code: ErrorCode.SyncError, + message: 'App Store purchase sync did not complete', + }); + expect(mockIap.getAvailablePurchases).not.toHaveBeenCalled(); + }); }); describe('Android-only wrappers', () => { diff --git a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts index 00c57eb1d..8badcd3f4 100644 --- a/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/type-bridge.test.ts @@ -397,13 +397,30 @@ describe('type-bridge utilities', () => { ).toBe(false); expect(consoleErrorSpy).toHaveBeenCalledWith( '[RN-IAP]', - 'NitroPurchase missing required field: store', - {id: 'id', productId: 'sku', transactionDate: 1}, + 'NitroPurchase has invalid required field: store', ); } finally { consoleErrorSpy.mockRestore(); } }); + + it('accepts only decodable object JSON for the Nitro iOS offer', () => { + expect( + validateNitroPurchase( + purchase({ + offerIOS: JSON.stringify({ + id: 'intro', + paymentMode: 'free-trial', + type: 'introductory', + }), + }), + ), + ).toBe(true); + expect(validateNitroPurchase(purchase({offerIOS: '{invalid'}))).toBe( + false, + ); + expect(validateNitroPurchase(purchase({offerIOS: '[]'}))).toBe(false); + }); }); it('keeps type synchronization healthy', () => { diff --git a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts index 7c2fe33e2..4cd3b3c0f 100644 --- a/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts +++ b/libraries/react-native-iap/src/__tests__/vega-adapter.test.ts @@ -963,7 +963,7 @@ describe('Amazon Vega adapter', () => { ]); }); - it('treats Amazon parser-only purchase update errors as no updates', async () => { + it('rejects Amazon parser-only purchase update errors atomically', async () => { const service = createService(); service.getPurchaseUpdates.mockRejectedValueOnce( new Error( @@ -972,7 +972,35 @@ describe('Amazon Vega adapter', () => { ); const module = createVegaIapModule(service); - await expect(module.getAvailablePurchases()).resolves.toEqual([]); + await expect(module.getAvailablePurchases()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); + }); + + it('rejects all pages when a later Amazon purchase update page is malformed', async () => { + const service = createService(); + service.getPurchaseUpdates + .mockResolvedValueOnce({ + responseCode: 1, + hasMore: true, + receiptList: [ + { + receiptId: 'receipt-page-1', + sku: 'coins_100', + productType: 1, + }, + ], + }) + .mockRejectedValueOnce( + new Error( + '[AmazonIAPSDK] Unable to parse the response : userId is not found while parsing Json', + ), + ); + const module = createVegaIapModule(service); + + await expect(module.getAvailablePurchases()).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); }); it('retries failed Amazon purchase update responses', async () => { @@ -1012,7 +1040,7 @@ describe('Amazon Vega adapter', () => { } }); - it('ignores parser-only product type hydration errors for purchase updates', async () => { + it('rejects parser-only product type hydration errors for purchase updates', async () => { const service = createService(); service.getPurchaseUpdates.mockResolvedValueOnce({ responseCode: 1, @@ -1033,7 +1061,9 @@ describe('Amazon Vega adapter', () => { await expect( module.getActiveSubscriptions(['premium_monthly']), - ).resolves.toEqual([]); + ).rejects.toMatchObject({ + code: ErrorCode.BillingResponseJsonParseError, + }); }); it('chunks Vega product data requests', async () => { diff --git a/libraries/react-native-iap/src/hooks/useIAP.ts b/libraries/react-native-iap/src/hooks/useIAP.ts index 104b6916e..e6d27eb01 100644 --- a/libraries/react-native-iap/src/hooks/useIAP.ts +++ b/libraries/react-native-iap/src/hooks/useIAP.ts @@ -2,6 +2,7 @@ import {useCallback, useEffect, useState, useRef} from 'react'; import {Platform} from 'react-native'; import {RnIapConsole} from '../utils/debug'; +import {createPurchaseError} from '../utils/errorMapping'; // Internal modules import { @@ -467,6 +468,7 @@ export function useIAP(options?: UseIapOptions): UseIap { } catch (error) { RnIapConsole.error('Error fetching available purchases:', error); invokeOnError(error); + throw error; } }, [invokeOnError], @@ -483,7 +485,7 @@ export function useIAP(options?: UseIapOptions): UseIap { } catch (error) { RnIapConsole.error('Error getting active subscriptions:', error); invokeOnError(error); - return []; + throw error; } }, [invokeOnError], @@ -496,7 +498,7 @@ export function useIAP(options?: UseIapOptions): UseIap { } catch (error) { RnIapConsole.error('Error checking active subscriptions:', error); invokeOnError(error); - return false; + throw error; } }, [invokeOnError], @@ -523,16 +525,26 @@ export function useIAP(options?: UseIapOptions): UseIap { const restorePurchases = useCallback( async (options?: PurchaseOptions): Promise => { - try { - if (Platform.OS === 'ios') { - await syncIOS(); + if (Platform.OS === 'ios') { + try { + const synced = await syncIOS(); + if (!synced) { + throw createPurchaseError({ + code: ErrorCode.SyncError, + message: 'App Store purchase sync did not complete', + platform: 'ios', + }); + } + } catch (error) { + RnIapConsole.warn('Failed to restore purchases:', error); + invokeOnError(error); + throw error; } - - await getAvailablePurchasesInternal(options); - } catch (error) { - RnIapConsole.warn('Failed to restore purchases:', error); - invokeOnError(error); } + + // The query helper reports and rethrows its own error, avoiding a second + // onError call while keeping restore failure observable to the caller. + await getAvailablePurchasesInternal(options); }, [getAvailablePurchasesInternal, invokeOnError], ); diff --git a/libraries/react-native-iap/src/index.kepler.ts b/libraries/react-native-iap/src/index.kepler.ts index 4af4b4203..92e0490f5 100644 --- a/libraries/react-native-iap/src/index.kepler.ts +++ b/libraries/react-native-iap/src/index.kepler.ts @@ -26,6 +26,7 @@ import { validateNitroProduct, validateNitroPurchase, } from './utils/type-bridge'; +import {convertAndroidPurchasesOrThrow} from './utils/available-purchases'; export * from './types'; export * from './utils/error'; @@ -128,7 +129,8 @@ export const fetchProducts = async ( const normalizedType = normalizeProductQueryType(type); const nitroProducts = await getModule().fetchProducts(skus, normalizedType); return mapProducts(nitroProducts, normalizedType) as - Product[] | ProductSubscription[]; + | Product[] + | ProductSubscription[]; }; export const requestPurchase: MutationField<'requestPurchase'> = async ( @@ -152,9 +154,7 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( const result = await getModule().requestPurchase(nitroRequest); if (!Array.isArray(result)) return null; const purchases = result as unknown as NitroPurchase[]; - return purchases - .filter(validateNitroPurchase) - .map((purchase) => convertNitroPurchaseToPurchase(purchase)); + return convertAndroidPurchasesOrThrow(purchases); }; export const getAvailablePurchases: QueryField< @@ -167,11 +167,7 @@ export const getAvailablePurchases: QueryField< }; return getModule() .getAvailablePurchases(nitroOptions) - .then((purchases) => - purchases - .filter(validateNitroPurchase) - .map(convertNitroPurchaseToPurchase), - ); + .then(convertAndroidPurchasesOrThrow); }; export const finishTransaction: MutationField<'finishTransaction'> = async ( diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index 8b7b27b4a..e3909edd7 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -59,6 +59,10 @@ import { import {RnIapConsole} from './utils/debug'; import {getSuccessFromPurchaseVariant} from './utils/purchase'; import {parseAppTransactionPayload} from './utils'; +import { + convertAndroidPurchasesOrThrow, + convertApplePurchasesOrThrow, +} from './utils/available-purchases'; import {getVegaIapModule, isVegaOS} from './vega'; // ------------------------------ @@ -884,7 +888,8 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { if (normalizedType === 'all') { const converted = (await fetchAndConvert('all')) as ( - Product | ProductSubscription + | Product + | ProductSubscription )[]; RnIapConsole.debug( @@ -991,14 +996,7 @@ export const getAvailablePurchases: QueryField< const nitroPurchases = await IAP.instance.getAvailablePurchases(nitroOptions); - const validPurchases = nitroPurchases.filter(validateNitroPurchase); - if (validPurchases.length !== nitroPurchases.length) { - RnIapConsole.warn( - `[getAvailablePurchases] Some purchases failed validation: ${nitroPurchases.length - validPurchases.length} invalid`, - ); - } - - return validPurchases.map(convertNitroPurchaseToPurchase); + return convertApplePurchasesOrThrow(nitroPurchases); } else if (isAndroidStoreRuntime()) { const includeSuspended = Boolean( options?.includeSuspendedAndroid ?? false, @@ -1008,14 +1006,7 @@ export const getAvailablePurchases: QueryField< const nitroPurchases = await IAP.instance.getAvailablePurchases({ android: {includeSuspended}, }); - const validPurchases = nitroPurchases.filter(validateNitroPurchase); - if (validPurchases.length !== nitroPurchases.length) { - RnIapConsole.warn( - `[getAvailablePurchases] Some Vega purchases failed validation: ${nitroPurchases.length - validPurchases.length} invalid`, - ); - } - - return validPurchases.map(convertNitroPurchaseToPurchase); + return convertAndroidPurchasesOrThrow(nitroPurchases); } // For Android Play/Horizon/Fire OS, query in-app items and subscriptions separately. @@ -1026,16 +1017,8 @@ export const getAvailablePurchases: QueryField< android: {type: 'subs', includeSuspended}, }); - // Validate and convert both sets of purchases const allNitroPurchases = [...inappNitroPurchases, ...subsNitroPurchases]; - const validPurchases = allNitroPurchases.filter(validateNitroPurchase); - if (validPurchases.length !== allNitroPurchases.length) { - RnIapConsole.warn( - `[getAvailablePurchases] Some Android purchases failed validation: ${allNitroPurchases.length - validPurchases.length} invalid`, - ); - } - - return validPurchases.map(convertNitroPurchaseToPurchase); + return convertAndroidPurchasesOrThrow(allNitroPurchases); } else { throw unsupportedPlatformError(); } @@ -1298,11 +1281,7 @@ export const getPendingTransactionsIOS: QueryField< try { const nitroPurchases = await IAP.instance.getPendingTransactionsIOS(); - return nitroPurchases - .map(convertNitroPurchaseToPurchase) - .filter( - (purchase): purchase is PurchaseIOS => purchase.store === 'apple', - ); + return convertApplePurchasesOrThrow(nitroPurchases); } catch (error) { const parsedError = parseErrorAndLogIfNeeded( '[getPendingTransactionsIOS] Failed:', @@ -1331,11 +1310,7 @@ export const getAllTransactionsIOS: QueryField< try { const nitroPurchases = await IAP.instance.getAllTransactionsIOS(); - return nitroPurchases - .map(convertNitroPurchaseToPurchase) - .filter( - (purchase): purchase is PurchaseIOS => purchase.store === 'apple', - ); + return convertApplePurchasesOrThrow(nitroPurchases); } catch (error) { const parsedError = parseErrorAndLogIfNeeded( '[getAllTransactionsIOS] Failed:', @@ -1366,11 +1341,7 @@ export const showManageSubscriptionsIOS: MutationField< try { const nitroPurchases = await IAP.instance.showManageSubscriptionsIOS(); - return nitroPurchases - .map(convertNitroPurchaseToPurchase) - .filter( - (purchase): purchase is PurchaseIOS => purchase.store === 'apple', - ); + return convertApplePurchasesOrThrow(nitroPurchases); } catch (error) { const parsedError = parseErrorAndLogIfNeeded( '[showManageSubscriptionsIOS] Failed:', @@ -1627,7 +1598,14 @@ export const endConnection: MutationField<'endConnection'> = async () => { export const restorePurchases: MutationField<'restorePurchases'> = async () => { try { if (Platform.OS === 'ios') { - await syncIOS(); + const synced = await syncIOS(); + if (!synced) { + throw createPurchaseError({ + code: ErrorCode.SyncError, + message: 'App Store purchase sync did not complete', + platform: 'ios', + }); + } } await getAvailablePurchases({ @@ -2485,8 +2463,7 @@ export const getActiveSubscriptions: QueryField< sub.renewalInfoIOS.autoRenewPreference ?? null, bundleOriginalTransactionId: sub.renewalInfoIOS.bundleOriginalTransactionId ?? null, - bundleProductId: - sub.renewalInfoIOS.bundleProductId ?? null, + bundleProductId: sub.renewalInfoIOS.bundleProductId ?? null, bundleSubscriptionGroupId: sub.renewalInfoIOS.bundleSubscriptionGroupId ?? null, commitmentInfo: sub.renewalInfoIOS.commitmentInfo ?? null, diff --git a/libraries/react-native-iap/src/utils/available-purchases.ts b/libraries/react-native-iap/src/utils/available-purchases.ts new file mode 100644 index 000000000..eee77e4c2 --- /dev/null +++ b/libraries/react-native-iap/src/utils/available-purchases.ts @@ -0,0 +1,73 @@ +import {ErrorCode} from '../types'; +import type {Purchase, PurchaseIOS} from '../types'; +import {createPurchaseError} from './errorMapping'; +import { + convertNitroPurchaseToPurchase, + validateNitroPurchase, +} from './type-bridge'; + +const ANDROID_STORES = new Set(['google', 'amazon', 'horizon']); + +/** Decode an authoritative native purchase list atomically. */ +export const convertAvailablePurchasesOrThrow = ( + purchases: unknown, +): Purchase[] => { + if (!Array.isArray(purchases)) { + throw createPurchaseError({ + code: ErrorCode.BillingResponseJsonParseError, + message: 'Malformed purchase list returned by the native bridge', + }); + } + + return purchases.map((purchase, index) => { + if (!validateNitroPurchase(purchase)) { + throw createPurchaseError({ + code: ErrorCode.BillingResponseJsonParseError, + message: `Malformed purchase payload returned by the native bridge at index ${index}`, + }); + } + + try { + return convertNitroPurchaseToPurchase(purchase); + } catch { + throw createPurchaseError({ + code: ErrorCode.BillingResponseJsonParseError, + message: `Failed to decode native purchase payload at index ${index}`, + }); + } + }); +}; + +/** Decode an authoritative StoreKit purchase list without filtering entries. */ +export const convertApplePurchasesOrThrow = ( + purchases: unknown, +): PurchaseIOS[] => { + const decoded = convertAvailablePurchasesOrThrow(purchases); + const invalidIndex = decoded.findIndex( + (purchase) => purchase.store !== 'apple', + ); + if (invalidIndex !== -1) { + throw createPurchaseError({ + code: ErrorCode.BillingResponseJsonParseError, + message: `Native StoreKit bridge returned a non-Apple purchase at index ${invalidIndex}`, + }); + } + return decoded as PurchaseIOS[]; +}; + +/** Decode an authoritative Android-family purchase list without foreign stores. */ +export const convertAndroidPurchasesOrThrow = ( + purchases: unknown, +): Purchase[] => { + const decoded = convertAvailablePurchasesOrThrow(purchases); + const invalidIndex = decoded.findIndex( + (purchase) => !ANDROID_STORES.has(purchase.store), + ); + if (invalidIndex !== -1) { + throw createPurchaseError({ + code: ErrorCode.BillingResponseJsonParseError, + message: `Native Android bridge returned a foreign purchase at index ${invalidIndex}`, + }); + } + return decoded; +}; diff --git a/libraries/react-native-iap/src/utils/type-bridge.ts b/libraries/react-native-iap/src/utils/type-bridge.ts index 986d606b5..5af5dead6 100644 --- a/libraries/react-native-iap/src/utils/type-bridge.ts +++ b/libraries/react-native-iap/src/utils/type-bridge.ts @@ -592,17 +592,80 @@ export function validateNitroPurchase(nitroPurchase: NitroPurchase): boolean { return false; } - const required = ['id', 'productId', 'transactionDate', 'store']; - for (const field of required) { + const invalidField = (field: string): false => { + RnIapConsole.error(`NitroPurchase has invalid required field: ${field}`); + return false; + }; + if (typeof nitroPurchase.id !== 'string' || !nitroPurchase.id) { + return invalidField('id'); + } + if (typeof nitroPurchase.productId !== 'string' || !nitroPurchase.productId) { + return invalidField('productId'); + } + if ( + typeof nitroPurchase.transactionDate !== 'number' || + !Number.isFinite(nitroPurchase.transactionDate) + ) { + return invalidField('transactionDate'); + } + if (typeof nitroPurchase.store !== 'string' || !nitroPurchase.store) { + return invalidField('store'); + } + if ( + typeof nitroPurchase.purchaseState !== 'string' || + !nitroPurchase.purchaseState + ) { + return invalidField('purchaseState'); + } + if ( + typeof nitroPurchase.quantity !== 'number' || + !Number.isInteger(nitroPurchase.quantity) + ) { + return invalidField('quantity'); + } + if (typeof nitroPurchase.isAutoRenewing !== 'boolean') { + return invalidField('isAutoRenewing'); + } + if ( + nitroPurchase.store === 'apple' && + (typeof nitroPurchase.transactionId !== 'string' || + !nitroPurchase.transactionId) + ) { + return invalidField('transactionId'); + } + const candidate = nitroPurchase as unknown as Record; + if ( + candidate.ids != null && + (!Array.isArray(candidate.ids) || + candidate.ids.some((id) => typeof id !== 'string')) + ) { + return invalidField('ids'); + } + for (const field of [ + 'pendingPurchaseUpdateAndroid', + 'renewalInfoIOS', + 'commitmentInfoIOS', + 'advancedCommerceInfoIOS', + ]) { + const nested = candidate[field]; if ( - !(field in nitroPurchase) || - nitroPurchase[field as keyof NitroPurchase] == null + nested != null && + (typeof nested !== 'object' || Array.isArray(nested)) ) { - RnIapConsole.error( - `NitroPurchase missing required field: ${field}`, - nitroPurchase, - ); - return false; + return invalidField(field); + } + } + if (candidate.offerIOS != null) { + if (typeof candidate.offerIOS !== 'string') { + return invalidField('offerIOS'); + } + try { + const offer = JSON.parse(candidate.offerIOS); + if (offer == null || typeof offer !== 'object' || Array.isArray(offer)) { + return invalidField('offerIOS'); + } + } catch { + return invalidField('offerIOS'); } } diff --git a/libraries/react-native-iap/src/vega-adapter.ts b/libraries/react-native-iap/src/vega-adapter.ts index 075ece761..b81b671b6 100644 --- a/libraries/react-native-iap/src/vega-adapter.ts +++ b/libraries/react-native-iap/src/vega-adapter.ts @@ -631,6 +631,14 @@ function isVegaParserError(error: unknown): boolean { ); } +function malformedVegaResponse(error: unknown, operation: string): Error { + const detail = error instanceof Error ? error.message : String(error); + return createVegaError( + ErrorCode.BillingResponseJsonParseError, + `${operation} returned a malformed response: ${detail}`, + ); +} + function createPricingPhase(product: VegaProduct) { return { billingCycleCount: 0, @@ -934,7 +942,7 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { response = await service.getPurchaseUpdates({reset}); } catch (error) { if (isVegaParserError(error)) { - return receipts; + throw malformedVegaResponse(error, 'Amazon Vega purchase updates'); } throw error; } @@ -1013,7 +1021,10 @@ export function createVegaIapModule(service: VegaPurchasingService): RnIap { ); } catch (error) { if (isVegaParserError(error)) { - return; + throw malformedVegaResponse( + error, + 'Amazon Vega purchase product metadata', + ); } throw error; } diff --git a/packages/apple/Sources/Models/OpenIapSerialization.swift b/packages/apple/Sources/Models/OpenIapSerialization.swift index 6506f0993..273bab645 100644 --- a/packages/apple/Sources/Models/OpenIapSerialization.swift +++ b/packages/apple/Sources/Models/OpenIapSerialization.swift @@ -42,6 +42,29 @@ public enum OpenIapSerialization { } } + /// Strict encoding for authoritative query results. Unlike `encode`, this + /// never turns a serialization failure into an empty success dictionary. + public static func encodeRequired(_ value: T) throws -> [String: Any] { + do { + let data = try encoder.encode(value) + guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + json["__typename"] = String(describing: T.self) + return json + } catch let error as PurchaseError { + throw error + } catch { + throw PurchaseError.make( + code: .billingResponseJsonParseError, + message: "Failed to serialize native \(T.self) payload" + ) + } + } + // MARK: - Decoding Helpers public static func decode( @@ -218,6 +241,17 @@ public enum OpenIapSerialization { items.map { purchase($0) } } + public static func purchasesRequired(_ items: [Purchase]) throws -> [[String: Any]] { + try items.map { item in + switch item { + case let .purchaseIos(value): + return try encodeRequired(value) + case let .purchaseAndroid(value): + return try encodeRequired(value) + } + } + } + public static func purchase(_ purchase: Purchase) -> [String: Any] { switch purchase { case let .purchaseIos(value): diff --git a/packages/apple/Sources/OpenIapModule+ObjC.swift b/packages/apple/Sources/OpenIapModule+ObjC.swift index b74b85c08..47e3532b5 100644 --- a/packages/apple/Sources/OpenIapModule+ObjC.swift +++ b/packages/apple/Sources/OpenIapModule+ObjC.swift @@ -280,7 +280,7 @@ import StoreKit Task { do { let purchases = try await getAvailablePurchases(nil) - let dictionaries = OpenIapSerialization.purchases(purchases) + let dictionaries = try OpenIapSerialization.purchasesRequired(purchases) completion(dictionaries, nil) } catch { completion(nil, error) @@ -296,7 +296,7 @@ import StoreKit do { let purchaseOptions = try options.map { try OpenIapSerialization.purchaseOptions(from: $0) } let purchases = try await getAvailablePurchases(purchaseOptions) - let dictionaries = OpenIapSerialization.purchases(purchases) + let dictionaries = try OpenIapSerialization.purchasesRequired(purchases) completion(dictionaries, nil) } catch { completion(nil, error) @@ -308,7 +308,7 @@ import StoreKit Task { do { let transactions = try await getAllTransactionsIOS() - let dictionaries = transactions.map { OpenIapSerialization.encode($0) } + let dictionaries = try transactions.map { try OpenIapSerialization.encodeRequired($0) } completion(dictionaries, nil) } catch { completion(nil, error) @@ -399,7 +399,7 @@ import StoreKit do { let transactions = try await getPendingTransactionsIOS() // Convert [PurchaseIOS] to dictionaries directly - let dictionaries = transactions.map { OpenIapSerialization.encode($0) } + let dictionaries = try transactions.map { try OpenIapSerialization.encodeRequired($0) } completion(dictionaries, nil) } catch { completion(nil, error) @@ -592,7 +592,7 @@ import StoreKit Task { do { let subscriptions = try await getActiveSubscriptions(nil) - let dictionaries = subscriptions.map { OpenIapSerialization.encode($0) } + let dictionaries = try subscriptions.map { try OpenIapSerialization.encodeRequired($0) } completion(dictionaries, nil) } catch { completion(nil, error) diff --git a/packages/apple/Sources/OpenIapModule.swift b/packages/apple/Sources/OpenIapModule.swift index b8a0ef51c..4c3ec087a 100644 --- a/packages/apple/Sources/OpenIapModule.swift +++ b/packages/apple/Sources/OpenIapModule.swift @@ -525,25 +525,23 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { var purchasedItems: [Purchase] = [] for await verification in (onlyActive ? Transaction.currentEntitlements : Transaction.all) { - do { - let transaction = try checkVerified(verification) - - if onlyActive, let expirationDate = transaction.expirationDate, expirationDate <= Date() { - continue - } + let transaction = try checkVerified(verification) - let purchase = await StoreKitTypesBridge.purchase( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) - purchasedItems.append(purchase) - if shouldPublish { - emitPurchaseUpdate(purchase) - } - } catch { - OpenIapLog.error("getAvailablePurchases: failed to verify transaction: \(error)") + if onlyActive, let expirationDate = transaction.expirationDate, expirationDate <= Date() { continue } + + let purchase = await StoreKitTypesBridge.purchase( + from: transaction, + jwsRepresentation: verification.jwsRepresentation + ) + purchasedItems.append(purchase) + } + + // Publish only after the complete sequence verifies successfully so a + // failed query cannot leak a partial authoritative result via events. + if shouldPublish { + purchasedItems.forEach { emitPurchaseUpdate($0) } } OpenIapLog.debug("🔍 getAvailablePurchases: \(purchasedItems.count) purchases (onlyActive=\(onlyActive))") @@ -563,17 +561,12 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { var transactions: [PurchaseIOS] = [] for await verification in Transaction.all { - do { - let transaction = try checkVerified(verification) - let purchase = await StoreKitTypesBridge.purchaseIOS( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) - transactions.append(purchase) - } catch { - OpenIapLog.error("getAllTransactionsIOS: failed to verify transaction: \(error)") - continue - } + let transaction = try checkVerified(verification) + let purchase = await StoreKitTypesBridge.purchaseIOS( + from: transaction, + jwsRepresentation: verification.jwsRepresentation + ) + transactions.append(purchase) } OpenIapLog.debug("🔍 getAllTransactionsIOS: \(transactions.count) transactions") @@ -646,18 +639,14 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { try await ensureConnection() var purchases: [PurchaseIOS] = [] for await verification in Transaction.unfinished { - do { - let transaction = try checkVerified(verification) - await state.storePending(id: String(transaction.id), transaction: transaction) - purchases.append( - await StoreKitTypesBridge.purchaseIOS( - from: transaction, - jwsRepresentation: verification.jwsRepresentation - ) + let transaction = try checkVerified(verification) + await state.storePending(id: String(transaction.id), transaction: transaction) + purchases.append( + await StoreKitTypesBridge.purchaseIOS( + from: transaction, + jwsRepresentation: verification.jwsRepresentation ) - } catch { - OpenIapLog.error("getPendingTransactionsIOS: failed to verify transaction: \(error)") - } + ) } return purchases } @@ -1012,58 +1001,54 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { try await ensureConnection() var allSubscriptions: [ActiveSubscription] = [] for await verification in Transaction.currentEntitlements { - do { - let transaction = try checkVerified(verification) - guard StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( - transaction.productType - ) else { - continue - } - - // Skip upgraded subscriptions - they've been replaced - if transaction.isUpgraded { - continue - } + let transaction = try checkVerified(verification) + guard StoreKitTypesBridge.isAutoRenewingSubscriptionProductType( + transaction.productType + ) else { + continue + } - if let ids = subscriptionIds, ids.contains(transaction.productID) == false { - continue - } - let expiration = transaction.expirationDate - // If expiration date is nil, treat as inactive (expired or invalid) - // This prevents treating subscriptions without expiration dates as active - let isActive = expiration.map { $0 > Date() } ?? false - let dayDelta = expiration.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 0 } - let daysUntilExpiration = dayDelta.map { Double($0) } - let environment: String? - // OpenIapModule already requires tvOS 16, so only the lower - // iOS and watchOS deployment floors need a runtime check here. - if #available(iOS 16.0, watchOS 9.0, *) { - environment = transaction.environment.rawValue - } else { - environment = nil - } + // Skip upgraded subscriptions - they've been replaced + if transaction.isUpgraded { + continue + } - // Fetch renewal info for subscription - let renewalInfo = await StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction) - - allSubscriptions.append( - ActiveSubscription( - autoRenewingAndroid: nil, - currentPlanId: transaction.productID, - daysUntilExpirationIOS: daysUntilExpiration, - environmentIOS: environment, - expirationDateIOS: expiration?.milliseconds, - isActive: isActive, - productId: transaction.productID, - purchaseToken: verification.jwsRepresentation, - renewalInfoIOS: renewalInfo, - transactionDate: transaction.purchaseDate.milliseconds, - transactionId: String(transaction.id) - ) - ) - } catch { + if let ids = subscriptionIds, ids.contains(transaction.productID) == false { continue } + let expiration = transaction.expirationDate + // If expiration date is nil, treat as inactive (expired or invalid) + // This prevents treating subscriptions without expiration dates as active + let isActive = expiration.map { $0 > Date() } ?? false + let dayDelta = expiration.map { Calendar.current.dateComponents([.day], from: Date(), to: $0).day ?? 0 } + let daysUntilExpiration = dayDelta.map { Double($0) } + let environment: String? + // OpenIapModule already requires tvOS 16, so only the lower + // iOS and watchOS deployment floors need a runtime check here. + if #available(iOS 16.0, watchOS 9.0, *) { + environment = transaction.environment.rawValue + } else { + environment = nil + } + + // Fetch renewal info for subscription + let renewalInfo = await StoreKitTypesBridge.subscriptionRenewalInfoIOS(for: transaction) + + allSubscriptions.append( + ActiveSubscription( + autoRenewingAndroid: nil, + currentPlanId: transaction.productID, + daysUntilExpirationIOS: daysUntilExpiration, + environmentIOS: environment, + expirationDateIOS: expiration?.milliseconds, + isActive: isActive, + productId: transaction.productID, + purchaseToken: verification.jwsRepresentation, + renewalInfoIOS: renewalInfo, + transactionDate: transaction.purchaseDate.milliseconds, + transactionId: String(transaction.id) + ) + ) } OpenIapLog.debug("📊 Returning \(allSubscriptions.count) active subscriptions") @@ -2071,7 +2056,7 @@ public final class OpenIapModule: NSObject, OpenIapModuleProtocol { return false } - private func checkVerified(_ result: VerificationResult) throws -> T { + func checkVerified(_ result: VerificationResult) throws -> T { switch result { case .verified(let value): return value diff --git a/packages/apple/Tests/OpenIapTests.swift b/packages/apple/Tests/OpenIapTests.swift index fb55ccba8..0b35736dd 100644 --- a/packages/apple/Tests/OpenIapTests.swift +++ b/packages/apple/Tests/OpenIapTests.swift @@ -4,6 +4,47 @@ import XCTest final class OpenIapTests: XCTestCase { + private struct ThrowingEncodable: Encodable { + struct EncodingFailure: Error {} + + func encode(to encoder: Encoder) throws { + throw EncodingFailure() + } + } + + func testRequiredSerializationPreservesFailures() throws { + XCTAssertTrue(try OpenIapSerialization.purchasesRequired([]).isEmpty) + + XCTAssertThrowsError( + try OpenIapSerialization.encodeRequired(ThrowingEncodable()) + ) { error in + XCTAssertEqual( + (error as? PurchaseError)?.code, + .billingResponseJsonParseError + ) + } + } + + func testTransactionVerificationFailureIsNotAnEmptyEntitlement() throws { + XCTAssertEqual( + try OpenIapModule.shared.checkVerified( + VerificationResult.verified(7) + ), + 7 + ) + + XCTAssertThrowsError( + try OpenIapModule.shared.checkVerified( + VerificationResult.unverified(7, .invalidSignature) + ) + ) { error in + XCTAssertEqual( + (error as? PurchaseError)?.code, + .transactionValidationFailed + ) + } + } + func testConnectedGenerationIsInvalidatedWhenEndingBegins() async throws { let lifecycle = OpenIapConnectionLifecycle() let initWork = try XCTUnwrap(lifecycle.makeInitTask { _ in true }) diff --git a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx index b7b59d791..226de2239 100644 --- a/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/get-active-subscriptions.tsx @@ -80,7 +80,12 @@ function GetActiveSubscriptions() { );`} ), gdscript: ( - {`func get_active_subscriptions(subscription_ids: Array[String] = []) -> Array[ActiveSubscription]`} + {`func get_active_subscriptions(subscription_ids: Array[String] = []) -> Array[ActiveSubscription] + +# Godot failure-aware variant: +func get_active_subscriptions_result(subscription_ids: Array[String] = []) -> Dictionary +# { "success": true, "subscriptions": Array[ActiveSubscription] } +# { "success": false, "code": String, "error": String }`} ), }} @@ -100,6 +105,18 @@ function GetActiveSubscriptions() { + + Failure semantics + +

+ Store, serialization, and bridge-decoding failures reject the query; + they are not an authoritative empty subscription list. The React Native + and Expo hooks call onError and rethrow. Godot entitlement + code must use get_active_subscriptions_result() and leave + its existing entitlement state unchanged when success is{' '} + false. +

+ Returns @@ -217,7 +234,11 @@ using OpenIap.Maui; var subscriptions = await ((QueryResolver)OpenIapClient.Instance).GetActiveSubscriptionsAsync();`} ), gdscript: ( - {`var subscriptions = await iap.get_active_subscriptions()`} + {`var result = await iap.get_active_subscriptions_result() +if not result.success: + push_error("Subscription query failed: %s (%s)" % [result.error, result.code]) + return +var subscriptions: Array = result.subscriptions`} ), }} diff --git a/packages/docs/src/pages/docs/apis/get-available-purchases.tsx b/packages/docs/src/pages/docs/apis/get-available-purchases.tsx index 4f98aee89..a1b27c623 100644 --- a/packages/docs/src/pages/docs/apis/get-available-purchases.tsx +++ b/packages/docs/src/pages/docs/apis/get-available-purchases.tsx @@ -81,7 +81,12 @@ interface PurchaseOptions { );`} ), gdscript: ( - {`func get_available_purchases(options: PurchaseOptions = null) -> Array[Purchase]`} + {`func get_available_purchases(options: PurchaseOptions = null) -> Array[Purchase] + +# Godot failure-aware variant: +func get_available_purchases_result(options: PurchaseOptions = null) -> Dictionary +# { "success": true, "purchases": Array[Purchase] } +# { "success": false, "code": String, "error": String }`} ), }} @@ -137,6 +142,27 @@ interface PurchaseOptions { — owned/available purchases held by the store.

+ + Failure semantics + +

+ In SDK APIs that surface failures and in Godot's result-bearing + API, an empty purchase list is an authoritative store result: the store + query completed and found no purchases. Native transaction verification, + serialization, or bridge decoding failures reject the whole query (or + return success = false) with an error such as{' '} + billing-response-json-parse-error; OpenIAP does not return + a partial list. +

+

+ Godot keeps get_available_purchases() for compatibility, so + that array-only method still maps a failure to an empty array. Code that + restores purchases, grants entitlements, or clears cached ownership must + call get_available_purchases_result() and check{' '} + success before using purchases. Never revoke + or clear entitlements after a failed query. +

+

Example

{{ @@ -201,7 +227,13 @@ using OpenIap.Maui; var purchases = await ((QueryResolver)OpenIapClient.Instance).GetAvailablePurchasesAsync();`} ), gdscript: ( - {`var purchases = await iap.get_available_purchases()`} + {`var result = await iap.get_available_purchases_result() +if not result.success: + push_error("Purchase query failed: %s (%s)" % [result.error, result.code]) + return + +# An empty array here is a confirmed, successful store result. +var purchases: Array = result.purchases`} ), }} diff --git a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx index c05b9f5e4..bc69c5a50 100644 --- a/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx +++ b/packages/docs/src/pages/docs/apis/has-active-subscriptions.tsx @@ -68,7 +68,12 @@ function HasActiveSubscriptions() { );`} ), gdscript: ( - {`func has_active_subscriptions(subscription_ids: Array[String] = []) -> bool`} + {`func has_active_subscriptions(subscription_ids: Array[String] = []) -> bool + +# Godot failure-aware variant: +func has_active_subscriptions_result(subscription_ids: Array[String] = []) -> Dictionary +# { "success": true, "hasActive": bool } +# { "success": false, "code": String, "error": String }`} ), }} @@ -98,6 +103,14 @@ function HasActiveSubscriptions() { {' '} when you only need a yes/no answer.

+

+ A store or bridge failure is not false. Promise-based SDKs + reject, and their React Native/Expo hooks call onError{' '} + before rethrowing. Godot entitlement code must use{' '} + has_active_subscriptions_result(); the compatibility + boolean helper still maps failure to false and is not safe + for granting or revoking access. +

Example

@@ -144,7 +157,11 @@ using OpenIap.Maui; var isPremium = await ((QueryResolver)OpenIapClient.Instance).HasActiveSubscriptionsAsync();`} ), gdscript: ( - {`var is_premium = await iap.has_active_subscriptions()`} + {`var result = await iap.has_active_subscriptions_result() +if not result.success: + push_error("Subscription status failed: %s (%s)" % [result.error, result.code]) + return +var is_premium: bool = result.hasActive`} ), }} diff --git a/packages/docs/src/pages/docs/apis/restore-purchases.tsx b/packages/docs/src/pages/docs/apis/restore-purchases.tsx index b3759c569..16728dead 100644 --- a/packages/docs/src/pages/docs/apis/restore-purchases.tsx +++ b/packages/docs/src/pages/docs/apis/restore-purchases.tsx @@ -71,7 +71,7 @@ function RestorePurchases() { {`Task RestorePurchasesAsync();`} ), gdscript: ( - {`func restore_purchases() -> void`} + {`func restore_purchases() -> Types.VoidResult`} ), }} @@ -85,7 +85,9 @@ function RestorePurchases() { purchaseUpdatedListener / surface as{' '} getAvailablePurchases results, depending on platform. In MAUI/C#, RestorePurchasesAsync returns{' '} - Task<VoidResult>. + Task<VoidResult>. Godot returns a{' '} + VoidResult; check success because a failed + store query must not be treated as a successful restore.

Example

@@ -171,7 +173,10 @@ using OpenIap.Maui; await ((MutationResolver)OpenIapClient.Instance).RestorePurchasesAsync();`} ), gdscript: ( - {`await iap.restore_purchases()`} + {`var result = await iap.restore_purchases() +if not result.success: + push_error("Purchase restore failed") + return`} ), }} diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 41932ca2b..6ab112384 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -287,6 +287,7 @@ class OpenIapModule( val client: BillingClient, val generation: Long, val callback: (Result>) -> Unit, + val errorEventGate: PurchaseErrorEventGate, val requestedSkus: Set, val requestedProductType: String?, val selectedBasePlanIdsBySku: Map = emptyMap(), @@ -294,6 +295,18 @@ class OpenIapModule( ) private var pendingPurchase: PendingPurchaseSnapshot? = null + private class PurchaseErrorEventGate( + private val publish: (OpenIapError) -> Unit, + ) { + private val published = AtomicBoolean(false) + + fun publishOnce(error: OpenIapError) { + if (published.compareAndSet(false, true)) { + publish(error) + } + } + } + private fun clearPurchaseStateLocked() { pendingPurchase = null } @@ -325,7 +338,9 @@ class OpenIapModule( expectedCallback, requireLaunched, ) ?: return - error?.withProductId(pending.requestedSkus.singleOrNull())?.let(::emitPurchaseError) + error?.withProductId(pending.requestedSkus.singleOrNull())?.let { + pending.errorEventGate.publishOnce(it) + } pending.callback(result) } @@ -335,7 +350,7 @@ class OpenIapModule( ) { val pending = claimPurchaseCallback(expectedClient) ?: return error.withProductId(pending.requestedSkus.singleOrNull()) - emitPurchaseError(error) + pending.errorEventGate.publishOnce(error) pending.callback(Result.failure(error)) } @@ -343,6 +358,7 @@ class OpenIapModule( expectedClient: BillingClient, requestedSkus: Set, requestedProductType: String, + errorEventGate: PurchaseErrorEventGate, callback: (Result>) -> Unit ): OpenIapError? = synchronized(connectionLifecycleLock) { when { @@ -357,6 +373,7 @@ class OpenIapModule( client = expectedClient, generation = connectionGeneration, callback = callback, + errorEventGate = errorEventGate, requestedSkus = requestedSkus.toSet(), requestedProductType = requestedProductType, ) @@ -685,7 +702,7 @@ class OpenIapModule( ) if (end.pendingPurchase != null) { disconnectError.withProductId(end.pendingPurchase.requestedSkus.singleOrNull()) - emitPurchaseError(disconnectError) + end.pendingPurchase.errorEventGate.publishOnce(disconnectError) end.pendingPurchase.callback(Result.failure(disconnectError)) } end.client?.endConnection() @@ -1554,26 +1571,27 @@ class OpenIapModule( } override val requestPurchase: MutationRequestPurchaseHandler = { props -> - val purchases = withContext(Dispatchers.IO) { + val errorEventGate = PurchaseErrorEventGate(::emitPurchaseError) + val purchases = try { withContext(Dispatchers.IO) { val androidArgs = props.toAndroidPurchaseArgs() val activity = currentActivityRef?.get() ?: fallbackActivity if (activity == null) { val err = OpenIapError.MissingCurrentActivity - emitPurchaseError(err) + errorEventGate.publishOnce(err) return@withContext emptyList() } val client = billingClient if (client == null || !client.isReady) { val err = OpenIapError.NotPrepared - emitPurchaseError(err) + errorEventGate.publishOnce(err) return@withContext emptyList() } if (androidArgs.skus.isEmpty()) { val err = OpenIapError.EmptySkuList - emitPurchaseError(err) + errorEventGate.publishOnce(err) return@withContext emptyList() } @@ -1586,7 +1604,7 @@ class OpenIapModule( val err = OpenIapError.DeveloperError( "subscriptionProductReplacementParams requires exactly one target SKU" ) - emitPurchaseError(err) + errorEventGate.publishOnce(err) return@withContext emptyList() } @@ -1598,7 +1616,7 @@ class OpenIapModule( val err = OpenIapError.DeveloperError( "purchaseToken and originalExternalTransactionId are mutually exclusive" ) - emitPurchaseError(err) + errorEventGate.publishOnce(err) return@withContext emptyList() } if ( @@ -1608,7 +1626,7 @@ class OpenIapModule( val err = OpenIapError.DeveloperError( "subscriptionProductReplacementParams requires exactly one update source" ) - emitPurchaseError(err) + errorEventGate.publishOnce(err) return@withContext emptyList() } @@ -1636,13 +1654,12 @@ class OpenIapModule( expectedClient = client, requestedSkus = androidArgs.skus.toSet(), requestedProductType = desiredType, + errorEventGate = errorEventGate, callback = callback, ) if (installError != null) { OpenIapLog.warn("requestPurchase rejected: ${installError.message}", TAG) - if (installError is OpenIapError.ServiceDisconnected) { - emitPurchaseError(installError) - } + errorEventGate.publishOnce(installError) resumer.resumeWithException(installError) return@suspendCancellableCoroutine } @@ -1979,6 +1996,17 @@ class OpenIapModule( } } } + } } catch (error: CancellationException) { + throw error + } catch (error: OpenIapError) { + errorEventGate.publishOnce(error) + throw error + } catch (error: Exception) { + val mapped = OpenIapError.PurchaseFailed( + error.message ?: "Unknown purchase error" + ) + errorEventGate.publishOnce(mapped) + throw mapped } RequestPurchaseResultPurchases(purchases) } diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt index 0c8a20825..667bc39a7 100644 --- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt +++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt @@ -48,6 +48,7 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import org.robolectric.shadows.ShadowLooper @@ -602,7 +603,9 @@ class OnPurchasesUpdatedRecoveryTest { launchStartedAtMillis = 1.0, ) val updates = mutableListOf() + val errors = mutableListOf() module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it }) + module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it }) val invalidated = AtomicBoolean(false) OpenIapLog.enable(true) @@ -626,6 +629,8 @@ class OnPurchasesUpdatedRecoveryTest { "endConnection must fail the pending request: $results", results.single().exceptionOrNull() is OpenIapError.ServiceDisconnected, ) + assertEquals("endConnection must publish one terminal purchase error", 1, errors.size) + assertTrue(errors.single() is OpenIapError.ServiceDisconnected) assertNull(pendingPurchaseField().get(module)) } @@ -660,6 +665,40 @@ class OnPurchasesUpdatedRecoveryTest { assertNull(pendingPurchaseField().get(module)) } + @Test + fun `connection replacement during callback install publishes one terminal error`() { + val module = module() + val invalidated = AtomicBoolean(false) + val client = RecordingBillingClient( + onReadyCheck = { + if (invalidated.compareAndSet(false, true)) { + setBillingClient(module, null) + } + }, + ) + setBillingClient(module, client) + module.setActivity(Robolectric.buildActivity(Activity::class.java).create().get()) + val errors = mutableListOf() + module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it }) + val props = RequestPurchaseProps( + request = RequestPurchaseProps.Request.Purchase( + RequestPurchasePropsByPlatforms( + google = RequestPurchaseAndroidProps(skus = listOf("product-id")), + ), + ), + type = ProductQueryType.InApp, + ) + + val thrown = runCatching { + runBlocking { module.requestPurchase(props) } + }.exceptionOrNull() + + assertTrue("readiness hook must replace the client", invalidated.get()) + assertTrue("install race must reject with ServiceDisconnected: $thrown", thrown is OpenIapError.ServiceDisconnected) + assertEquals("the install path and outer catch must share one event gate", 1, errors.size) + assertTrue(errors.single() is OpenIapError.ServiceDisconnected) + } + private fun module(): OpenIapModule = OpenIapModule(ApplicationProvider.getApplicationContext()) @@ -689,6 +728,18 @@ class OnPurchasesUpdatedRecoveryTest { selectedBasePlanIdsBySku: Map = emptyMap(), ) { val snapshotClass = Class.forName("dev.hyo.openiap.OpenIapModule\$PendingPurchaseSnapshot") + val gateClass = Class.forName("dev.hyo.openiap.OpenIapModule\$PurchaseErrorEventGate") + val emitMethod = OpenIapModule::class.java.getDeclaredMethod( + "emitPurchaseError", + OpenIapError::class.java, + ).apply { isAccessible = true } + val publisher: (OpenIapError) -> Unit = { error -> + emitMethod.invoke(module, error) + } + val gateConstructor = gateClass.declaredConstructors.single().apply { + isAccessible = true + } + val errorEventGate = gateConstructor.newInstance(publisher) val constructor = snapshotClass.declaredConstructors.first { candidate -> candidate.parameterTypes.none { it.simpleName == "DefaultConstructorMarker" } } @@ -697,6 +748,7 @@ class OnPurchasesUpdatedRecoveryTest { client, 0L, callback, + errorEventGate, skus, productType, selectedBasePlanIdsBySku, @@ -735,6 +787,7 @@ class OnPurchasesUpdatedRecoveryTest { private class RecordingBillingClient( private val ownedPurchases: List = emptyList(), purchaseResponseCodes: List = emptyList(), + private val onReadyCheck: (() -> Unit)? = null, ) : BillingClient() { val queryPurchasesCalls = AtomicInteger(0) var beforeQueryPurchasesResponse: (() -> Unit)? = null @@ -755,7 +808,10 @@ class OnPurchasesUpdatedRecoveryTest { listener.onQueryPurchasesResponse(result, ownedPurchases) } - override fun isReady(): Boolean = true + override fun isReady(): Boolean { + onReadyCheck?.invoke() + return true + } override fun getConnectionState(): Int = ConnectionState.CONNECTED diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 19cec0cc2..fe76c6a91 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1495,9 +1495,34 @@ function checkFlutter() { ); expectIncludes( "libraries/expo-iap/src/index.ts", + ["restorePurchasesIOSNative"], + "Expo restore helper routing", + ); + expectIncludes( + "libraries/expo-iap/src/utils/restorePurchases.ts", ["nativeModule.USING_ONSIDE_SDK", "nativeModule.restorePurchases"], "Expo Onside restore routing", ); + expectNotIncludes( + "libraries/expo-iap/src/utils/restorePurchases.ts", + ["catch(() => undefined)"], + "Expo restore failures must remain observable", + ); + expectIncludes( + "libraries/expo-iap/android/src/main/java/expo/modules/iap/ExpoIapModule.kt", + [ + "deliverPurchaseRequestFailure(", + "isCancellation = e is CancellationException", + "reachedOpenIapRequest = reachedOpenIapRequest", + "ExpoIapHelper.rejectPurchasePromises(code, message, null)", + ], + "Expo Android purchase failures must settle pending promises", + ); + expectMatch( + "libraries/react-native-iap/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt", + /openIap\.requestPurchase\(requestProps\)[\s\S]*?catch \(e: CancellationException\) \{\s*throw e\s*\}[\s\S]*?catch \(e: Exception\)/, + "RN Android purchase coroutine cancellation propagation", + ); expectIncludes( "libraries/expo-iap/src/ExpoIapModule.ts", [ @@ -1678,7 +1703,7 @@ function checkKmp() { "requestPurchaseWithPayload(params.toJson().toObjCMap())", "requireIosSku(params)", "openIapModule.verifyPurchaseWithSku(sku)", - "filterActiveSubscriptions(result, subscriptionIds)", + "decodeActiveSubscriptionListPayloadIOS(result, subscriptionIds)", ], "KMP iOS requestPurchase bridge", ); @@ -2215,6 +2240,88 @@ function checkBillingChoiceFieldBindings() { ], "RN Billing Choice hook wiring", ); + expectMatch( + "libraries/react-native-iap/src/hooks/useIAP.ts", + /const getAvailablePurchasesInternal[\s\S]*?catch \(error\) \{[\s\S]*?invokeOnError\(error\);\s*throw error;[\s\S]*?const getActiveSubscriptionsInternal/, + "RN authoritative purchase-query hook failure propagation", + ); + expectMatch( + "libraries/react-native-iap/src/hooks/useIAP.ts", + /const getActiveSubscriptionsInternal[\s\S]*?catch \(error\) \{[\s\S]*?invokeOnError\(error\);\s*throw error;[\s\S]*?const hasActiveSubscriptionsInternal[\s\S]*?catch \(error\) \{[\s\S]*?invokeOnError\(error\);\s*throw error;/, + "RN subscription entitlement hook failure propagation", + ); + expectMatch( + "libraries/react-native-iap/src/hooks/useIAP.ts", + /const restorePurchases[\s\S]*?const synced = await syncIOS\(\);\s*if \(!synced\)[\s\S]*?ErrorCode\.SyncError[\s\S]*?invokeOnError\(error\);\s*throw error;[\s\S]*?await getAvailablePurchasesInternal\(options\);/, + "RN restore hook failure propagation", + ); + expectMatch( + "libraries/react-native-iap/src/index.ts", + /export const restorePurchases[\s\S]*?const synced = await syncIOS\(\);\s*if \(!synced\)[\s\S]*?ErrorCode\.SyncError[\s\S]*?await getAvailablePurchases/, + "RN restore API false-sync rejection", + ); + expectIncludes( + "libraries/react-native-iap/src/index.ts", + [ + "return convertApplePurchasesOrThrow(nitroPurchases);", + "return convertAndroidPurchasesOrThrow(allNitroPurchases);", + ], + "RN platform-scoped authoritative purchase-list decoding", + ); + expectIncludes( + "libraries/react-native-iap/src/vega-adapter.ts", + [ + "throw malformedVegaResponse(error, 'Amazon Vega purchase updates')", + "'Amazon Vega purchase product metadata'", + "ErrorCode.BillingResponseJsonParseError", + ], + "RN Vega authoritative purchase-query failure propagation", + ); + expectIncludes( + "libraries/expo-iap/src/vega-adapter.ts", + [ + "throw malformedVegaResponse(error, 'Amazon Vega purchase updates')", + "'Amazon Vega purchase product metadata'", + "ErrorCode.BillingResponseJsonParseError", + ], + "Expo Vega authoritative purchase-query failure propagation", + ); + expectIncludes( + "libraries/expo-iap/src/modules/ios.ts", + [ + "return decodeApplePurchases(purchases);", + "return decodeApplePurchases(transactions);", + ], + "Expo authoritative StoreKit list decoding", + ); + expectIncludes( + "libraries/expo-iap/src/index.ts", + [ + "? decodeApplePurchases(purchases)", + ": decodeAndroidPurchases(purchases);", + ], + "Expo platform-scoped authoritative purchase-list decoding", + ); + expectMatch( + "libraries/expo-iap/src/useIAP.ts", + /const getActiveSubscriptionsInternal[\s\S]*?invokeOnError\(error\);\s*throw error;[\s\S]*?const hasActiveSubscriptionsInternal[\s\S]*?invokeOnError\(error\);\s*throw error;/, + "Expo subscription entitlement hook failure propagation", + ); + expectMatch( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + /showManageSubscriptionsIOS[\s\S]*?rejectMalformed: true[\s\S]*?get getPendingTransactionsIOS[\s\S]*?rejectMalformed: true[\s\S]*?get getAllTransactionsIOS[\s\S]*?rejectMalformed: true/, + "Flutter authoritative StoreKit list decoding", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/lib/helpers.dart", + [ + "if (platformIsIOS && store != 'apple') return false;", + "store != 'google' &&", + "store != 'amazon' &&", + "store != 'horizon'", + ], + "Flutter platform-scoped authoritative purchase-list decoding", + ); expectIncludes( "libraries/react-native-iap/src/__tests__/hooks/useIAP.android.test.ts", [ diff --git a/scripts/audit-purchase-payload-parity.mjs b/scripts/audit-purchase-payload-parity.mjs index 0012bf893..5785c28de 100644 --- a/scripts/audit-purchase-payload-parity.mjs +++ b/scripts/audit-purchase-payload-parity.mjs @@ -40,6 +40,17 @@ function expectIncludes(relativePath, needles, label = relativePath) { } } +function expectNotIncludes(relativePath, needles, label = relativePath) { + expectFile(relativePath); + if (!exists(relativePath)) return; + const text = read(relativePath); + for (const needle of needles) { + if (text.includes(needle)) { + fail(`${label} must not include ${JSON.stringify(needle)}`); + } + } +} + function expectSameSet(label, expected, actual) { const expectedSet = new Set(expected); const actualSet = new Set(actual); @@ -1243,13 +1254,108 @@ function checkFlutterPayloadContracts() { applePluginPath, [ "OpenIapSerialization.purchase(purchase)", - "OpenIapSerialization.purchases(purchases)", + "FlutterIapHelper.purchasesRequired(purchases)", ], `${applePluginPath} native purchase serialization`, ); } } +function checkStrictAppleFrameworkQuerySerialization() { + for (const [helperPath, helperName] of [ + ["libraries/react-native-iap/ios/RnIapHelper.swift", "RnIapHelper"], + ["libraries/expo-iap/ios/ExpoIapHelper.swift", "ExpoIapHelper"], + [ + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift", + "FlutterIapHelper", + ], + [ + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterIapHelper.swift", + "FlutterIapHelper", + ], + [ + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIapHelper.swift", + "GodotIapHelper", + ], + ]) { + expectIncludes( + helperPath, + [ + "OpenIapSerialization.purchase(purchase)", + "guard !encoded.isEmpty", + ".billingResponseJsonParseError", + // The bridge checks below require these helpers to be *called*. Assert + // they are also *defined*, otherwise a bridge can reference a helper + // that does not exist and the audit still passes while the Swift build + // fails — which is exactly how `RnIapHelper.encodeRequired` shipped + // missing. + "static func encodeRequired", + "static func purchasesRequired", + ], + `${helperName} strict native purchase serialization`, + ); + } + + for (const [bridgePath, helperName] of [ + ["libraries/react-native-iap/ios/HybridRnIap.swift", "RnIapHelper"], + ["libraries/expo-iap/ios/ExpoIapModule.swift", "ExpoIapHelper"], + [ + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "FlutterIapHelper", + ], + [ + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "FlutterIapHelper", + ], + [ + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", + "GodotIapHelper", + ], + ]) { + expectIncludes( + bridgePath, + [`${helperName}.purchasesRequired(`], + `${helperName} authoritative purchase-query routing`, + ); + expectNotIncludes( + bridgePath, + [ + "OpenIapSerialization.purchasesRequired(", + "OpenIapSerialization.encodeRequired(", + ], + `${helperName} published-native compatibility`, + ); + } + + expectIncludes( + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", + [ + "// OpenIAP requestPurchase emits its canonical error exactly once", + "code: ErrorCode.purchaseError.rawValue,\n message: error.localizedDescription,\n productId: productId", + ], + "Godot iOS request-purchase terminal error delivery", + ); + expectIncludes( + "libraries/godot-iap/addons/godot-iap/godot_iap.gd", + [ + 'if result.get("status", "") == "pending" or result.get("pending", false):\n\t\treturn null', + 'if _platform == "iOS" and store != "apple":', + 'store not in ["google", "amazon", "horizon"]', + ], + "Godot pending dispatch and platform-scoped purchase decoding", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui/BridgePayloadDecoder.cs", + [ + "ios.Store is not IapStore.Apple", + "android.Store != IapStore.Google", + "android.Store != IapStore.Amazon", + "android.Store != IapStore.Horizon", + ], + "MAUI platform-scoped authoritative purchase-list decoding", + ); +} + function checkReactNativePurchasePayloadContracts() { const generatedTypesPath = "packages/gql/src/generated/types.ts"; const nitroSpecPath = "libraries/react-native-iap/src/specs/RnIap.nitro.ts"; @@ -2025,16 +2131,111 @@ function checkPurchaseRoundTripRegressionCoverage() { ); } +function checkActiveSubscriptionFailureContracts() { + expectIncludes( + "packages/apple/Sources/OpenIapModule.swift", + [ + "for await verification in Transaction.currentEntitlements {\n let transaction = try checkVerified(verification)", + ], + "Apple active-subscription verification must be atomic", + ); + expectIncludes( + "packages/apple/Sources/OpenIapModule+ObjC.swift", + [ + "let dictionaries = try subscriptions.map { try OpenIapSerialization.encodeRequired($0) }", + ], + "Apple active-subscription bridge serialization", + ); + for (const [relativePath, needle] of [ + [ + "libraries/react-native-iap/ios/HybridRnIap.swift", + "try subscriptions.map { try RnIapHelper.encodeRequired($0) }", + ], + [ + "libraries/expo-iap/ios/ExpoIapModule.swift", + "ExpoIapHelper.sanitizeDictionary(try ExpoIapHelper.encodeRequired($0))", + ], + [ + "libraries/flutter_inapp_purchase/ios/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "try subscriptions.map { try FlutterIapHelper.encodeRequired($0) }", + ], + [ + "libraries/flutter_inapp_purchase/macos/flutter_inapp_purchase/Sources/flutter_inapp_purchase/FlutterInappPurchasePlugin.swift", + "try subscriptions.map { try FlutterIapHelper.encodeRequired($0) }", + ], + [ + "libraries/godot-iap/ios-gdextension/Sources/GodotIap/GodotIap.swift", + "try GodotIapHelper.encodeRequired($0)", + ], + ]) { + expectIncludes( + relativePath, + [needle], + `${relativePath} active-subscription serialization`, + ); + } + expectIncludes( + "libraries/kmp-iap/library/src/iosMain/kotlin/io/github/hyochan/kmpiap/ProductPayloadNormalizerIOS.kt", + [ + "internal fun decodeActiveSubscriptionListPayloadIOS(", + 'normalized["isActive"] !is Boolean', + "!transactionDate.toDouble().isFinite()", + ], + "KMP active-subscription list decoding", + ); + expectIncludes( + "libraries/maui-iap/src/OpenIap.Maui/Platforms/iOS/OpenIapIOS.cs", + [ + 'operation: "getActiveSubscriptions"', + ], + "MAUI active-subscription list decoding", + ); + expectIncludes( + "libraries/godot-iap/addons/godot-iap/godot_iap.gd", + [ + "func get_active_subscriptions_result(", + "func has_active_subscriptions_result(", + 'not value.get("isActive") is bool', + ], + "Godot failure-aware active-subscription APIs", + ); + expectIncludes( + "libraries/godot-iap/android/src/main/java/dev/hyo/godotiap/GodotIap.kt", + [ + "fun getActiveSubscriptionsResult(subscriptionIdsJson: String?): String", + 'put("success", false)', + 'put("code", code)', + ], + "Godot Android active-subscription result envelope", + ); + expectIncludes( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + [ + "Unexpected active-subscription response type:", + "Native active-subscription response contained malformed fields", + "return activeSubscriptions.isNotEmpty;", + ], + "Flutter active-subscription failure propagation", + ); + expectNotIncludes( + "libraries/flutter_inapp_purchase/lib/flutter_inapp_purchase.dart", + ["If there's an error getting subscriptions, return false"], + "Flutter active-subscription failure propagation", + ); +} + export function collectPurchasePayloadParityFailures(repoRoot) { root = repoRoot; failures = []; checkFlutterPayloadContracts(); + checkStrictAppleFrameworkQuerySerialization(); checkReactNativePurchasePayloadContracts(); checkReactNativeActiveSubscriptionPayloadContracts(); checkKmpPurchasePayloadContracts(); checkGooglePurchasePayloadContracts(); checkVegaPurchasePayloadContracts(); checkPurchaseRoundTripRegressionCoverage(); + checkActiveSubscriptionFailureContracts(); return [...failures]; }