diff --git a/CLAUDE.md b/CLAUDE.md index 8ed60b563..cebfea165 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -249,12 +249,14 @@ yarn install && yarn typecheck && yarn lint --fix ## Hook API Semantics (useIAP) - Inside the `useIAP` hook, most methods return `Promise` and update internal state. Do not design examples that expect returned data from these methods. - - Examples: `fetchProducts`, `requestProducts` (if present), `requestPurchase`, `getAvailablePurchases`. + - Examples: `fetchProducts`, `requestPurchase`, `getAvailablePurchases`. - After calling, consume state from the hook: `products`, `subscriptions`, `availablePurchases`, etc. + - For `requestPurchase`: Use `onPurchaseSuccess` callback to receive purchase results, NOT the return value. - Defined exceptions in the hook that DO return values: - `getActiveSubscriptions(subscriptionIds?) => Promise` (also updates `activeSubscriptions` state) - `hasActiveSubscriptions(subscriptionIds?) => Promise` - The root (index) API is value-returning and can be awaited to receive data directly. Use root API when not using React state. + - Example: `const result = await requestPurchase({...})` returns `Promise` (though native returns empty array by design - actual results come through event listeners). ### Common CI Fixes @@ -297,7 +299,7 @@ The project uses a centralized error handling approach across all platforms: - `getUserFriendlyErrorMessage()` - **Public helper** - Get user-friendly error messages - `ErrorCode` enum (from types.ts) - Standardized error codes across platforms -**Android & iOS (OpenIAP)** +### Android & iOS (OpenIAP) Both platforms use the OpenIAP library's error handling: diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 1ca48a591..2ffec2352 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -254,6 +254,12 @@ class HybridRnIap : HybridRnIapSpec() { runCatching { openIap.endConnection() } productTypeBySku.clear() isInitialized = false + listenersAttached = false + purchaseUpdatedListeners.clear() + purchaseErrorListeners.clear() + promotedProductListenersIOS.clear() + userChoiceBillingListenersAndroid.clear() + developerProvidedBillingListenersAndroid.clear() initDeferred = null RnIapLog.result("endConnection", true) true diff --git a/ios/HybridRnIap.swift b/ios/HybridRnIap.swift index 7c8dc6a4a..64cfc7516 100644 --- a/ios/HybridRnIap.swift +++ b/ios/HybridRnIap.swift @@ -1034,8 +1034,8 @@ class HybridRnIap: HybridRnIapSpec { updateListenerTask?.cancel() updateListenerTask = nil isInitialized = false - - + isInitializing = false + // Remove OpenIAP listeners & end connection if let sub = purchaseUpdatedSub { RnIapLog.payload("removeListener", "purchaseUpdated") diff --git a/src/__tests__/hooks/useIAP.test.ts b/src/__tests__/hooks/useIAP.test.ts index eb22a1b11..27e883040 100644 --- a/src/__tests__/hooks/useIAP.test.ts +++ b/src/__tests__/hooks/useIAP.test.ts @@ -118,6 +118,30 @@ describe('hooks/useIAP (renderer)', () => { expect(IAP.finishTransaction).toBeDefined(); }); + it('requestPurchase calls root API and returns void', async () => { + const mockRequestPurchase = jest + .spyOn(IAP, 'requestPurchase') + .mockResolvedValue(null); + + let api: any; + const Harness = () => { + api = useIAP(); + return null; + }; + + await act(async () => { + TestRenderer.create(React.createElement(Harness)); + }); + await act(async () => {}); + + await act(async () => { + const result = await api.requestPurchase({sku: 'product1'}); + expect(result).toBeUndefined(); + }); + + expect(mockRequestPurchase).toHaveBeenCalledWith({sku: 'product1'}); + }); + describe('onError callback', () => { it('calls onError when fetchProducts fails', async () => { const fetchError = new Error('Network error fetching products'); diff --git a/src/__tests__/index.test.ts b/src/__tests__/index.test.ts index c8b1bc4e0..242233d0f 100644 --- a/src/__tests__/index.test.ts +++ b/src/__tests__/index.test.ts @@ -223,6 +223,81 @@ describe('Public API (src/index.ts)', () => { expect(mockIap.initConnection).toHaveBeenCalled(); expect(mockIap.endConnection).toHaveBeenCalled(); }); + + it('listeners work after endConnection → initConnection reconnection', async () => { + // 1. Initial connection + listener + await IAP.initConnection(); + const listener1 = jest.fn(); + const sub1 = IAP.purchaseUpdatedListener(listener1); + + // Verify listener is registered + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); + const wrapped1 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + + // Simulate a purchase event — listener should fire + const nitroPurchase = { + id: 't1', + productId: 'p1', + transactionDate: Date.now(), + platform: 'ios', + quantity: 1, + purchaseState: 'purchased', + isAutoRenewing: false, + }; + wrapped1(nitroPurchase); + expect(listener1).toHaveBeenCalledTimes(1); + + // 2. Disconnect and remove old listener + sub1.remove(); + await IAP.endConnection(); + + // 3. Reconnect and register new listener + jest.clearAllMocks(); + await IAP.initConnection(); + const listener2 = jest.fn(); + const sub2 = IAP.purchaseUpdatedListener(listener2); + + // New listener should be registered with native + expect(mockIap.addPurchaseUpdatedListener).toHaveBeenCalledTimes(1); + const wrapped2 = mockIap.addPurchaseUpdatedListener.mock.calls[0][0]; + + // Simulate purchase event on new connection — new listener should fire + wrapped2(nitroPurchase); + expect(listener2).toHaveBeenCalledTimes(1); + expect(listener2).toHaveBeenCalledWith( + expect.objectContaining({productId: 'p1'}), + ); + + sub2.remove(); + }); + + it('error listeners work after endConnection → initConnection reconnection', async () => { + await IAP.initConnection(); + const errorListener1 = jest.fn(); + const sub1 = IAP.purchaseErrorListener(errorListener1); + sub1.remove(); + await IAP.endConnection(); + + // Reconnect and register new error listener + jest.clearAllMocks(); + await IAP.initConnection(); + const errorListener2 = jest.fn(); + const sub2 = IAP.purchaseErrorListener(errorListener2); + + expect(mockIap.addPurchaseErrorListener).toHaveBeenCalledTimes(1); + const wrapped = mockIap.addPurchaseErrorListener.mock.calls[0][0]; + + wrapped({code: 'user-cancelled', message: 'User cancelled'}); + expect(errorListener2).toHaveBeenCalledTimes(1); + expect(errorListener2).toHaveBeenCalledWith( + expect.objectContaining({ + code: ErrorCode.UserCancelled, + message: 'User cancelled', + }), + ); + + sub2.remove(); + }); }); describe('fetchProducts', () => { diff --git a/src/hooks/useIAP.ts b/src/hooks/useIAP.ts index dab920e7c..11351df59 100644 --- a/src/hooks/useIAP.ts +++ b/src/hooks/useIAP.ts @@ -33,7 +33,6 @@ import {ErrorCode} from '../types'; import type { ProductQueryType, RequestPurchaseProps, - RequestPurchaseResult, AlternativeBillingModeAndroid, BillingProgramAndroid, UserChoiceBillingDetails, @@ -69,9 +68,7 @@ type UseIap = { skus: string[]; type?: ProductQueryType | null; }) => Promise; - requestPurchase: ( - params: RequestPurchaseProps, - ) => Promise; + requestPurchase: (params: RequestPurchaseProps) => Promise; /** * @deprecated Use `verifyPurchase` instead. This function will be removed in a future version. */ @@ -330,7 +327,9 @@ export function useIAP(options?: UseIapOptions): UseIap { ); const requestPurchase = useCallback( - (requestObj: RequestPurchaseProps) => requestPurchaseInternal(requestObj), + async (requestObj: RequestPurchaseProps): Promise => { + await requestPurchaseInternal(requestObj); + }, [], );