From d9a0e40d9854df8a0207a53f97c6cf242cdc015d Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 22:36:12 +0900 Subject: [PATCH 1/7] fix(react-native-iap): parse Nitro iOS cancellation errors Closes #202 --- .../src/__tests__/index.test.ts | 17 ++ .../src/__tests__/utils/error.test.ts | 43 +++++ libraries/react-native-iap/src/index.ts | 181 ++++++++++++------ libraries/react-native-iap/src/utils/error.ts | 146 +++++++++++++- 4 files changed, 327 insertions(+), 60 deletions(-) diff --git a/libraries/react-native-iap/src/__tests__/index.test.ts b/libraries/react-native-iap/src/__tests__/index.test.ts index b407a27c6..7e5b511d1 100644 --- a/libraries/react-native-iap/src/__tests__/index.test.ts +++ b/libraries/react-native-iap/src/__tests__/index.test.ts @@ -1240,6 +1240,23 @@ describe('Public API (src/index.ts)', () => { await expect(IAP.syncIOS()).resolves.toBe(true); }); + it('syncIOS preserves Nitro user cancellation without error logging', async () => { + (Platform as any).OS = 'ios'; + mockIap.syncIOS = jest.fn(async () => { + throw new Error( + 'Error Domain=com.margelo.nitro.rniap Code=-1 ' + + '"{\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}" ' + + 'UserInfo={NSLocalizedDescription={\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}}', + ); + }); + + await expect(IAP.syncIOS()).rejects.toMatchObject({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + }); + expect(console.error).not.toHaveBeenCalled(); + }); + it('restorePurchases on iOS calls syncIOS first', async () => { (Platform as any).OS = 'ios'; mockIap.syncIOS = jest.fn(async () => true); diff --git a/libraries/react-native-iap/src/__tests__/utils/error.test.ts b/libraries/react-native-iap/src/__tests__/utils/error.test.ts index 30b72cc96..d48ab7cbc 100644 --- a/libraries/react-native-iap/src/__tests__/utils/error.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/error.test.ts @@ -47,6 +47,49 @@ describe('Error utilities', () => { }); }); + it('should preserve structured code from Error objects', () => { + const error = Object.assign(new Error('Request Canceled'), { + code: ErrorCode.UserCancelled, + }); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + }); + }); + + it('should parse Nitro NSError string with embedded JSON payload', () => { + const error = new Error( + 'Error Domain=com.margelo.nitro.rniap Code=-1 ' + + '"{\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}" ' + + 'UserInfo={NSLocalizedDescription={\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}}', + ); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + }); + expect(isUserCancelledError(error)).toBe(true); + }); + + it('should parse escaped UserInfo JSON when NSError quoted payload is plain text', () => { + const error = new Error( + 'Error Domain=SKErrorDomain Code=-1 "The operation failed." ' + + 'UserInfo={NSLocalizedDescription={\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}}', + ); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + }); + }); + it('should handle non-JSON string', () => { const errorString = 'Not a JSON string'; diff --git a/libraries/react-native-iap/src/index.ts b/libraries/react-native-iap/src/index.ts index c6ad3c5b3..fa3102279 100644 --- a/libraries/react-native-iap/src/index.ts +++ b/libraries/react-native-iap/src/index.ts @@ -55,7 +55,7 @@ import { validateNitroPurchase, convertNitroSubscriptionStatusToSubscriptionStatusIOS, } from './utils/type-bridge'; -import {parseErrorStringToJsonObj} from './utils/error'; +import {isUserCancelledError, parseErrorStringToJsonObj} from './utils/error'; import { normalizeErrorCodeFromNative, createPurchaseError, @@ -121,6 +121,17 @@ const toErrorMessage = (error: unknown): string => { return String(error ?? ''); }; +const parseErrorAndLogIfNeeded = ( + message: string, + error: unknown, +): ReturnType => { + const parsedError = parseErrorStringToJsonObj(error); + if (!isUserCancelledError(parsedError)) { + RnIapConsole.error(message, error); + } + return parsedError; +}; + const unsupportedPlatformError = (): Error => new Error(`Unsupported platform: ${Platform.OS}`); @@ -905,8 +916,10 @@ export const fetchProducts: QueryField<'fetchProducts'> = async (request) => { return convertedProducts as FetchProductsResult; } catch (error) { - RnIapConsole.error('[fetchProducts] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[fetchProducts] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1027,8 +1040,10 @@ export const getPromotedProductIOS: QueryField< const converted = convertNitroProductToProduct(nitroProduct); return converted.platform === 'ios' ? (converted as ProductIOS) : null; } catch (error) { - RnIapConsole.error('[getPromotedProductIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[getPromotedProductIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1181,8 +1196,10 @@ export const subscriptionStatusIOS: QueryField< .filter((status): status is NitroSubscriptionStatus => status != null) .map(convertNitroSubscriptionStatusToSubscriptionStatusIOS); } catch (error) { - RnIapConsole.error('[subscriptionStatusIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[subscriptionStatusIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1215,8 +1232,10 @@ export const currentEntitlementIOS: QueryField< } return null; } catch (error) { - RnIapConsole.error('[currentEntitlementIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[currentEntitlementIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1249,8 +1268,10 @@ export const latestTransactionIOS: QueryField<'latestTransactionIOS'> = async ( } return null; } catch (error) { - RnIapConsole.error('[latestTransactionIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[latestTransactionIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1282,8 +1303,10 @@ export const getPendingTransactionsIOS: QueryField< (purchase): purchase is PurchaseIOS => purchase.platform === 'ios', ); } catch (error) { - RnIapConsole.error('[getPendingTransactionsIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[getPendingTransactionsIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1313,8 +1336,10 @@ export const getAllTransactionsIOS: QueryField< (purchase): purchase is PurchaseIOS => purchase.platform === 'ios', ); } catch (error) { - RnIapConsole.error('[getAllTransactionsIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[getAllTransactionsIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1346,8 +1371,10 @@ export const showManageSubscriptionsIOS: MutationField< (purchase): purchase is PurchaseIOS => purchase.platform === 'ios', ); } catch (error) { - RnIapConsole.error('[showManageSubscriptionsIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[showManageSubscriptionsIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1375,8 +1402,10 @@ export const isEligibleForIntroOfferIOS: QueryField< try { return await IAP.instance.isEligibleForIntroOfferIOS(groupID); } catch (error) { - RnIapConsole.error('[isEligibleForIntroOfferIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[isEligibleForIntroOfferIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1407,8 +1436,10 @@ export const getReceiptDataIOS: QueryField<'getReceiptDataIOS'> = async () => { try { return await IAP.instance.getReceiptDataIOS(); } catch (error) { - RnIapConsole.error('[getReceiptDataIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[getReceiptDataIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1435,8 +1466,10 @@ export const getReceiptIOS = async (): Promise => { } return await IAP.instance.getReceiptDataIOS(); } catch (error) { - RnIapConsole.error('[getReceiptIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[getReceiptIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1463,8 +1496,10 @@ export const requestReceiptRefreshIOS = async (): Promise => { } return await IAP.instance.getReceiptDataIOS(); } catch (error) { - RnIapConsole.error('[requestReceiptRefreshIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[requestReceiptRefreshIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1492,8 +1527,10 @@ export const isTransactionVerifiedIOS: QueryField< try { return await IAP.instance.isTransactionVerifiedIOS(sku); } catch (error) { - RnIapConsole.error('[isTransactionVerifiedIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[isTransactionVerifiedIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1521,8 +1558,10 @@ export const getTransactionJwsIOS: QueryField<'getTransactionJwsIOS'> = async ( try { return await IAP.instance.getTransactionJwsIOS(sku); } catch (error) { - RnIapConsole.error('[getTransactionJwsIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[getTransactionJwsIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1563,8 +1602,10 @@ export const initConnection: MutationField<'initConnection'> = async ( config as Record | undefined, ); } catch (error) { - RnIapConsole.error('Failed to initialize IAP connection:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to initialize IAP connection:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1586,8 +1627,10 @@ export const endConnection: MutationField<'endConnection'> = async () => { resetListenerState(); return result; } catch (error) { - RnIapConsole.error('Failed to end IAP connection:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to end IAP connection:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1613,8 +1656,10 @@ export const restorePurchases: MutationField<'restorePurchases'> = async () => { onlyIncludeActiveItemsIOS: true, }); } catch (error) { - RnIapConsole.error('Failed to restore purchases:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to restore purchases:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1798,8 +1843,10 @@ export const requestPurchase: MutationField<'requestPurchase'> = async ( return await IAP.instance.requestPurchase(unifiedRequest); } catch (error) { - RnIapConsole.error('Failed to request purchase:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to request purchase:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1921,8 +1968,10 @@ export const acknowledgePurchaseAndroid: MutationField< }); return getSuccessFromPurchaseVariant(result, 'acknowledgePurchaseAndroid'); } catch (error) { - RnIapConsole.error('Failed to acknowledge purchase Android:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to acknowledge purchase Android:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -1960,8 +2009,10 @@ export const consumePurchaseAndroid: MutationField< }); return getSuccessFromPurchaseVariant(result, 'consumePurchaseAndroid'); } catch (error) { - RnIapConsole.error('Failed to consume purchase Android:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to consume purchase Android:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2109,8 +2160,10 @@ export const validateReceipt: MutationField<'validateReceipt'> = async ( return result; } } catch (error) { - RnIapConsole.error('[validateReceipt] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[validateReceipt] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2203,8 +2256,10 @@ export const verifyPurchaseWithProvider: MutationField< errors: result.errors ?? null, }; } catch (error) { - RnIapConsole.error('[verifyPurchaseWithProvider] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[verifyPurchaseWithProvider] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2230,8 +2285,7 @@ export const syncIOS: MutationField<'syncIOS'> = async () => { const result = await IAP.instance.syncIOS(); return Boolean(result); } catch (error) { - RnIapConsole.error('[syncIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded('[syncIOS] Failed:', error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2259,8 +2313,10 @@ export const presentCodeRedemptionSheetIOS: MutationField< const result = await IAP.instance.presentCodeRedemptionSheetIOS(); return Boolean(result); } catch (error) { - RnIapConsole.error('[presentCodeRedemptionSheetIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[presentCodeRedemptionSheetIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2315,11 +2371,10 @@ export const requestPurchaseOnPromotedProductIOS = return true; } catch (error) { - RnIapConsole.error( + const parsedError = parseErrorAndLogIfNeeded( '[requestPurchaseOnPromotedProductIOS] Failed:', error, ); - const parsedError = parseErrorStringToJsonObj(error); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2347,8 +2402,10 @@ export const clearTransactionIOS: MutationField< await IAP.instance.clearTransactionIOS(); return true; } catch (error) { - RnIapConsole.error('[clearTransactionIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[clearTransactionIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2377,8 +2434,10 @@ export const beginRefundRequestIOS: MutationField< const status = await IAP.instance.beginRefundRequestIOS(sku); return status ?? null; } catch (error) { - RnIapConsole.error('[beginRefundRequestIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[beginRefundRequestIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2430,8 +2489,10 @@ export const deepLinkToSubscriptionsIOS = async (): Promise => { await IAP.instance.showManageSubscriptionsIOS(); return true; } catch (error) { - RnIapConsole.error('[deepLinkToSubscriptionsIOS] Failed:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + '[deepLinkToSubscriptionsIOS] Failed:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, @@ -2512,8 +2573,10 @@ export const getActiveSubscriptions: QueryField< RnIapConsole.error('IAP connection not initialized:', error); throw error; } - RnIapConsole.error('Failed to get active subscriptions:', error); - const parsedError = parseErrorStringToJsonObj(error); + const parsedError = parseErrorAndLogIfNeeded( + 'Failed to get active subscriptions:', + error, + ); throw createPurchaseError({ code: parsedError.code, message: parsedError.message, diff --git a/libraries/react-native-iap/src/utils/error.ts b/libraries/react-native-iap/src/utils/error.ts index c5abd361a..688096d16 100644 --- a/libraries/react-native-iap/src/utils/error.ts +++ b/libraries/react-native-iap/src/utils/error.ts @@ -16,12 +16,146 @@ export interface IapError { [key: string]: any; // Allow additional platform-specific fields } +const parseJsonPayload = ( + payload: string, + fallbackMessage: string, +): IapError | null => { + const trimmed = payload.trim(); + const candidates = [trimmed]; + + if (trimmed.includes('\\"')) { + candidates.push(trimmed.replace(/\\"/g, '"')); + } + + for (const candidate of candidates) { + try { + const parsed = JSON.parse(candidate); + if (typeof parsed === 'object' && parsed !== null) { + const parsedError = parsed as Partial; + return { + code: parsedError.code || ErrorCode.Unknown, + message: parsedError.message || fallbackMessage, + ...parsedError, + }; + } + } catch { + // Try the next candidate. + } + } + + return null; +}; + +const extractBalancedJsonObject = ( + value: string, + startIndex: number, +): string | null => { + let depth = 0; + let isInsideString = false; + let isEscaped = false; + + for (let index = startIndex; index < value.length; index += 1) { + const char = value[index]; + + if (isInsideString) { + if (isEscaped) { + isEscaped = false; + } else if (char === '\\') { + isEscaped = true; + } else if (char === '"') { + isInsideString = false; + } + continue; + } + + if (char === '\\' && value[index + 1] === '"') { + index += 1; + continue; + } + + if (char === '"') { + isInsideString = true; + } else if (char === '{') { + depth += 1; + } else if (char === '}') { + depth -= 1; + if (depth === 0) { + return value.substring(startIndex, index + 1); + } + } + } + + return null; +}; + +const parseNSErrorJsonPayload = ( + rawString: string, +): IapError | null => { + const payloads: string[] = []; + const quotedPayloadMatch = /Code=-?\d+\s+"/.exec(rawString); + + if (quotedPayloadMatch?.index !== undefined) { + const contentStart = quotedPayloadMatch.index + quotedPayloadMatch[0].length; + const userInfoIndex = rawString.indexOf('UserInfo=', contentStart); + const contentEnd = + userInfoIndex > contentStart + ? rawString.lastIndexOf('"', userInfoIndex) + : -1; + + if (contentEnd > contentStart) { + payloads.push(rawString.substring(contentStart, contentEnd)); + } + } + + const localizedDescription = 'NSLocalizedDescription='; + const descriptionIndex = rawString.indexOf(localizedDescription); + + if (descriptionIndex >= 0) { + let valueStart = descriptionIndex + localizedDescription.length; + while (/\s/.test(rawString[valueStart] ?? '')) { + valueStart += 1; + } + + if (rawString[valueStart] === '{') { + const payload = extractBalancedJsonObject(rawString, valueStart); + if (payload) { + payloads.push(payload); + } + } + } + + for (const payload of payloads) { + const parsed = parseJsonPayload(payload, rawString); + if (parsed?.code && parsed.code !== ErrorCode.Unknown) { + return parsed; + } + } + + return null; +}; + +const parseStructuredError = (error: Error): IapError | null => { + const errorWithCode = error as Error & {code?: unknown}; + + if ( + typeof errorWithCode.code === 'string' && + errorWithCode.code.length > 0 + ) { + return { + code: errorWithCode.code, + message: error.message, + }; + } + + return null; +}; + /** * Parses error string from native modules into a structured error object * * Native modules return errors in different formats: * - Android: JSON string like '{"code":"E_USER_CANCELLED","message":"User cancelled the purchase","responseCode":1}' - * - iOS: JSON string or plain message + * - iOS: JSON string, Nitro NSError string with embedded JSON, or plain message * - Legacy: "CODE: message" format * * @param errorString - The error string from native module @@ -32,6 +166,11 @@ export function parseErrorStringToJsonObj( ): IapError { // Handle Error objects if (errorString instanceof Error) { + const structuredError = parseStructuredError(errorString); + if (structuredError) { + return structuredError; + } + errorString = errorString.message; } @@ -58,6 +197,11 @@ export function parseErrorStringToJsonObj( // Not JSON, continue with other formats } + const nsErrorPayload = parseNSErrorJsonPayload(errorString); + if (nsErrorPayload) { + return nsErrorPayload; + } + // Try to parse "CODE: message" format const colonIndex = errorString.indexOf(':'); if (colonIndex > 0 && colonIndex < 50) { From ab5db07e505b5607793ff35d0c94e58fcc02a80f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 23:23:18 +0900 Subject: [PATCH 2/7] fix(kmp-iap): preserve iOS purchase error codes Extends #203 coverage for iOS NSError completions. --- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 86 ++++++++++++------- 1 file changed, 55 insertions(+), 31 deletions(-) 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 0fa7c92d5..a20559de1 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 @@ -81,6 +81,30 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { private var promotedProductSubscription: NSObject? = null private var subscriptionBillingIssueSubscription: NSObject? = null + private fun NSError.toPurchaseException( + fallbackCode: ErrorCode = ErrorCode.Unknown + ): PurchaseException { + val code = stringFromUserInfo("code") + ?.let { raw -> runCatching { ErrorCode.fromJson(raw) }.getOrNull() } + ?: fallbackCode + val message = stringFromUserInfo("message") + ?: localizedDescription.ifBlank { "iOS error" } + + return PurchaseException( + PurchaseError( + code = code, + message = message, + productId = stringFromUserInfo("productId"), + debugMessage = stringFromUserInfo("debugMessage") + ) + ) + } + + private fun NSError.stringFromUserInfo(key: String): String? { + val value = userInfo[key] ?: userInfo[NSString.create(string = key)] + return value?.toString()?.takeIf { it.isNotBlank() } + } + init { // Register listeners setupListeners() @@ -162,7 +186,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // iOS doesn't use alternative billing config, it's Android only openIapModule.initConnectionWithCompletion { success, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { isConnected = success // Re-register listeners after endConnection()/initConnection() cycles. @@ -195,7 +219,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.endConnectionWithCompletion { success, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { isConnected = false continuation.resume(success) @@ -228,7 +252,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.requestPurchaseWithPayload(params.toJson().toObjCMap()) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { val purchase = convertAnyToPurchase(result) continuation.resume(purchase?.let { RequestPurchaseResultPurchase(it) }) @@ -283,7 +307,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.requestPurchaseOnPromotedProductIOSWithCompletion { success, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(success) } @@ -298,7 +322,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override suspend fun restorePurchases(): Unit = suspendCoroutine { continuation -> openIapModule.restorePurchasesWithCompletion { error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(Unit) } @@ -329,7 +353,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { isConsumable = isConsumable ?: false ) { error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(Unit) } @@ -345,7 +369,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.deepLinkToSubscriptionsWithCompletion { error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(Unit) } @@ -361,7 +385,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.presentCodeRedemptionSheetIOSWithCompletion { success, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(success) } @@ -377,7 +401,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.beginRefundRequestIOSWithSku(sku) { status, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(status) } @@ -392,7 +416,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override suspend fun clearTransactionIOS(): Boolean = suspendCoroutine { continuation -> openIapModule.clearTransactionIOSWithCompletion { success, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(success) } @@ -408,7 +432,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.showManageSubscriptionsIOSWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { val purchases = convertAnyListToPurchaseIOSList(result) continuation.resume(purchases) @@ -426,7 +450,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override suspend fun syncIOS(): Boolean = suspendCoroutine { continuation -> openIapModule.syncIOSWithCompletion { success, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else { continuation.resume(success) } @@ -466,7 +490,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.fetchProductsWithSkus(skus, type = type) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { // Convert [Any] to products or subscriptions based on type when (params.type) { @@ -508,7 +532,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.getAvailablePurchasesWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { val purchases = convertAnyListToPurchases(result) continuation.resume(purchases) @@ -551,7 +575,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.getPendingTransactionsIOSWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { val purchases = convertAnyListToPurchaseIOSList(result) continuation.resume(purchases) @@ -570,7 +594,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.getAllTransactionsIOSWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { val purchases = convertAnyListToPurchaseIOSList(result) continuation.resume(purchases) @@ -603,7 +627,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { override suspend fun getPromotedProductIOS(): ProductIOS? = suspendCoroutine { continuation -> openIapModule.getPromotedProductIOSWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) } else if (result != null) { val product = convertAnyToProductIOS(result) continuation.resume(product) @@ -622,7 +646,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.getActiveSubscriptionsWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@getActiveSubscriptionsWithCompletion } @@ -641,7 +665,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // The @available annotation on the Swift side handles version checking openIapModule.getAppTransactionIOSWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@getAppTransactionIOSWithCompletion } @@ -667,7 +691,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.currentEntitlementIOSWithSku(sku) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@currentEntitlementIOSWithSku } @@ -685,7 +709,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.getTransactionJwsIOSWithSku(sku) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@getTransactionJwsIOSWithSku } @@ -703,7 +727,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { if (subscriptionIds.isNullOrEmpty()) { openIapModule.hasActiveSubscriptionsWithCompletion { hasActive, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@hasActiveSubscriptionsWithCompletion } @@ -714,7 +738,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { openIapModule.getActiveSubscriptionsWithCompletion { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@getActiveSubscriptionsWithCompletion } @@ -752,7 +776,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.isEligibleForIntroOfferIOSWithGroupID(groupID) { isEligible, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@isEligibleForIntroOfferIOSWithGroupID } @@ -769,7 +793,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.isEligibleForExternalPurchaseCustomLinkIOSWithCompletion { isEligible, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@isEligibleForExternalPurchaseCustomLinkIOSWithCompletion } @@ -788,7 +812,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.showExternalPurchaseCustomLinkNoticeIOSWithNoticeType(noticeType.rawValue) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@showExternalPurchaseCustomLinkNoticeIOSWithNoticeType } @@ -823,7 +847,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.getExternalPurchaseCustomLinkTokenIOSWithTokenType(tokenType.rawValue) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@getExternalPurchaseCustomLinkTokenIOSWithTokenType } @@ -856,7 +880,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.isTransactionVerifiedIOSWithSku(sku) { isVerified, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@isTransactionVerifiedIOSWithSku } @@ -873,7 +897,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.latestTransactionIOSWithSku(sku) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@latestTransactionIOSWithSku } @@ -891,7 +915,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { suspendCoroutine { continuation -> openIapModule.subscriptionStatusIOSWithSku(sku) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@subscriptionStatusIOSWithSku } @@ -927,7 +951,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { return suspendCoroutine { continuation -> openIapModule.verifyPurchaseWithSku(sku) { result, error -> if (error != null) { - continuation.resumeWithException(Exception(error.localizedDescription)) + continuation.resumeWithException(error.toPurchaseException()) return@verifyPurchaseWithSku } From a79a6e796c3c2861bb9c99ceafead0d49280f675 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 23:32:48 +0900 Subject: [PATCH 3/7] docs: add cancellation hotfix notes Document the assumed React Native 15.3.6 and KMP 2.3.6 patch releases for issue #202.\n\nClarify UserCancelled handling in the error docs and replace the stale TypeScript E_* example values with OpenIAP kebab-case error codes. --- packages/docs/src/pages/docs/errors.tsx | 120 ++++++++++----- .../docs/src/pages/docs/updates/releases.tsx | 138 ++++++++++++++++++ 2 files changed, 219 insertions(+), 39 deletions(-) diff --git a/packages/docs/src/pages/docs/errors.tsx b/packages/docs/src/pages/docs/errors.tsx index 51a5dd438..1ef3cff21 100644 --- a/packages/docs/src/pages/docs/errors.tsx +++ b/packages/docs/src/pages/docs/errors.tsx @@ -227,6 +227,14 @@ var is_empty_product_list: Variant = null # Android returned no products`} +
+ User cancellation: The canonical serialized value is{' '} + user-cancelled. Some wrappers still normalize legacy{' '} + E_USER_CANCELLED aliases for compatibility, but new app + code should compare the generated ErrorCode.UserCancelled{' '} + enum value or the user-cancelled wire value. +
+
Android diagnostics: QueryProduct errors from openiap-google 2.1.4 and later include the Google @@ -450,6 +458,40 @@ var is_empty_product_list: Variant = null # Android returned no products`} +

User Cancellation

+

+ Treat user cancellation as an expected result of the purchase flow. Do + not show an error alert, retry automatically, or report it as a + service failure. +

+ + {{ + typescript: ( + {`import { ErrorCode } from 'react-native-iap'; + +function isUserCancellation(error: { code?: unknown }) { + return error.code === ErrorCode.UserCancelled || error.code === 'user-cancelled'; +}`} + ), + kmp: ( + {`try { + // Call a purchase or iOS-only API. +} catch (error: PurchaseException) { + if (error.error.code == ErrorCode.UserCancelled) return + throw error +}`} + ), + dart: ( + {`try { + // Call a purchase or iOS-only API. +} on PurchaseError catch (error) { + if (error.code == ErrorCode.UserCancelled) return; + rethrow; +}`} + ), + }} + +

Retry Strategy

Implement retry logic for transient errors:

@@ -643,48 +685,48 @@ var is_empty_product_list: Variant = null # Android returned no products`}{`enum ErrorCode { - Unknown = 'E_UNKNOWN', - UserCancelled = 'E_USER_CANCELLED', - UserError = 'E_USER_ERROR', - ItemUnavailable = 'E_ITEM_UNAVAILABLE', - RemoteError = 'E_REMOTE_ERROR', - NetworkError = 'E_NETWORK_ERROR', - ServiceError = 'E_SERVICE_ERROR', + Unknown = 'unknown', + UserCancelled = 'user-cancelled', + UserError = 'user-error', + ItemUnavailable = 'item-unavailable', + RemoteError = 'remote-error', + NetworkError = 'network-error', + ServiceError = 'service-error', // @deprecated Use PurchaseVerificationFailed instead - ReceiptFailed = 'E_RECEIPT_FAILED', + ReceiptFailed = 'receipt-failed', // @deprecated Use PurchaseVerificationFinished instead - ReceiptFinished = 'E_RECEIPT_FINISHED', + ReceiptFinished = 'receipt-finished', // @deprecated Use PurchaseVerificationFinishFailed instead - ReceiptFinishedFailed = 'E_RECEIPT_FINISHED_FAILED', - PurchaseVerificationFailed = 'E_PURCHASE_VERIFICATION_FAILED', - PurchaseVerificationFinished = 'E_PURCHASE_VERIFICATION_FINISHED', - PurchaseVerificationFinishFailed = 'E_PURCHASE_VERIFICATION_FINISH_FAILED', - NotPrepared = 'E_NOT_PREPARED', - NotEnded = 'E_NOT_ENDED', - AlreadyOwned = 'E_ALREADY_OWNED', - DeveloperError = 'E_DEVELOPER_ERROR', - BillingResponseJsonParseError = 'E_BILLING_RESPONSE_JSON_PARSE_ERROR', - DeferredPayment = 'E_DEFERRED_PAYMENT', - Interrupted = 'E_INTERRUPTED', - IapNotAvailable = 'E_IAP_NOT_AVAILABLE', - PurchaseError = 'E_PURCHASE_ERROR', - SyncError = 'E_SYNC_ERROR', - TransactionValidationFailed = 'E_TRANSACTION_VALIDATION_FAILED', - ActivityUnavailable = 'E_ACTIVITY_UNAVAILABLE', - AlreadyPrepared = 'E_ALREADY_PREPARED', - Pending = 'E_PENDING', - ConnectionClosed = 'E_CONNECTION_CLOSED', - InitConnection = 'E_INIT_CONNECTION', - ServiceDisconnected = 'E_SERVICE_DISCONNECTED', - ServiceTimeout = 'E_SERVICE_TIMEOUT', - QueryProduct = 'E_QUERY_PRODUCT', - SkuNotFound = 'E_SKU_NOT_FOUND', - SkuOfferMismatch = 'E_SKU_OFFER_MISMATCH', - ItemNotOwned = 'E_ITEM_NOT_OWNED', - BillingUnavailable = 'E_BILLING_UNAVAILABLE', - FeatureNotSupported = 'E_FEATURE_NOT_SUPPORTED', - EmptySkuList = 'E_EMPTY_SKU_LIST', - DuplicatePurchase = 'E_DUPLICATE_PURCHASE', + ReceiptFinishedFailed = 'receipt-finished-failed', + PurchaseVerificationFailed = 'purchase-verification-failed', + PurchaseVerificationFinished = 'purchase-verification-finished', + PurchaseVerificationFinishFailed = 'purchase-verification-finish-failed', + NotPrepared = 'not-prepared', + NotEnded = 'not-ended', + AlreadyOwned = 'already-owned', + DeveloperError = 'developer-error', + BillingResponseJsonParseError = 'billing-response-json-parse-error', + DeferredPayment = 'deferred-payment', + Interrupted = 'interrupted', + IapNotAvailable = 'iap-not-available', + PurchaseError = 'purchase-error', + SyncError = 'sync-error', + TransactionValidationFailed = 'transaction-validation-failed', + ActivityUnavailable = 'activity-unavailable', + AlreadyPrepared = 'already-prepared', + Pending = 'pending', + ConnectionClosed = 'connection-closed', + InitConnection = 'init-connection', + ServiceDisconnected = 'service-disconnected', + ServiceTimeout = 'service-timeout', + QueryProduct = 'query-product', + SkuNotFound = 'sku-not-found', + SkuOfferMismatch = 'sku-offer-mismatch', + ItemNotOwned = 'item-not-owned', + BillingUnavailable = 'billing-unavailable', + FeatureNotSupported = 'feature-not-supported', + EmptySkuList = 'empty-sku-list', + DuplicatePurchase = 'duplicate-purchase', }`} ), swift: ( diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx index cdd75cb24..d56a0bbc7 100644 --- a/packages/docs/src/pages/docs/updates/releases.tsx +++ b/packages/docs/src/pages/docs/updates/releases.tsx @@ -26,6 +26,144 @@ function Releases() { useScrollToHash(); const allNotes: Note[] = [ + // July 2, 2026 — iOS cancellation error bridge hotfix + { + id: 'ios-cancellation-error-bridge-hotfix-2026-07-02', + date: new Date('2026-07-02'), + element: ( +
+ + July 2, 2026 — iOS cancellation error bridge hotfix + + +

+ Publishes React Native and KMP patch releases for iOS cancellation + errors reported in{' '} + + issue #202 + {' '} + and fixed in{' '} + + PR #203 + + . The OpenIAP spec and native Apple package versions are unchanged; + this release updates the framework bridges that were dropping or + obscuring the original iOS user-cancelled error code. +

+ +
    +
  • + React Native Nitro error parsing — iOS{' '} + NSError messages from Nitro are parsed for embedded + OpenIAP JSON payloads so syncIOS() and other iOS + calls preserve ErrorCode.UserCancelled instead of + falling back to unknown or logging expected user + cancellation as an error. +
  • +
  • + KMP iOS completion errors — KMP now converts iOS + completion NSError values into{' '} + PurchaseException with the original{' '} + PurchaseError.code, message, product ID, and debug + message metadata from the native bridge. +
  • +
  • + Cross-SDK audit — Expo, Flutter, Godot, and MAUI + were checked for the same iOS error-code loss. They already use + typed error payloads or preserve native{' '} + NSError.userInfo metadata, so no matching release + change was required. +
  • +
  • + User cancellation behavior — Apps should continue + treating UserCancelled / user-cancelled{' '} + as expected user action, not a service failure. +
  • +
+ +
+
Package Releases
+ +
+
+ ), + }, + // July 2, 2026 — iOS subscription commitment billing plans { id: 'ios-subscription-commitment-billing-plans-2026-07-02', From 6a802579e3946995c47bd79cc5e2ea55c8f21ac4 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 23:35:53 +0900 Subject: [PATCH 4/7] fix(rn): harden iOS error payload parsing Address review feedback by preserving structured Error fields, reusing the shared JSON payload parser, parsing quoted NSError payloads without UserInfo, and covering escaped UserInfo payloads that contain braces. --- .../src/__tests__/utils/error.test.ts | 45 +++++++++++ libraries/react-native-iap/src/utils/error.ts | 74 ++++++------------- 2 files changed, 66 insertions(+), 53 deletions(-) diff --git a/libraries/react-native-iap/src/__tests__/utils/error.test.ts b/libraries/react-native-iap/src/__tests__/utils/error.test.ts index d48ab7cbc..f0fe81ea4 100644 --- a/libraries/react-native-iap/src/__tests__/utils/error.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/error.test.ts @@ -60,6 +60,23 @@ describe('Error utilities', () => { }); }); + it('should preserve structured fields from Error objects', () => { + const error = Object.assign(new Error('Request Canceled'), { + code: ErrorCode.UserCancelled, + productId: 'premium_monthly', + responseCode: 1, + }); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + productId: 'premium_monthly', + responseCode: 1, + }); + }); + it('should parse Nitro NSError string with embedded JSON payload', () => { const error = new Error( 'Error Domain=com.margelo.nitro.rniap Code=-1 ' + @@ -76,6 +93,20 @@ describe('Error utilities', () => { expect(isUserCancelledError(error)).toBe(true); }); + it('should parse quoted NSError JSON without UserInfo', () => { + const error = new Error( + 'Error Domain=com.margelo.nitro.rniap Code=-1 ' + + '"{\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}"', + ); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + }); + }); + it('should parse escaped UserInfo JSON when NSError quoted payload is plain text', () => { const error = new Error( 'Error Domain=SKErrorDomain Code=-1 "The operation failed." ' + @@ -90,6 +121,20 @@ describe('Error utilities', () => { }); }); + it('should parse escaped UserInfo JSON with braces in message', () => { + const error = new Error( + 'Error Domain=SKErrorDomain Code=-1 "The operation failed." ' + + 'UserInfo={NSLocalizedDescription={\\"message\\":\\"Request {Canceled}\\",\\"code\\":\\"user-cancelled\\"}}', + ); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request {Canceled}', + }); + }); + it('should handle non-JSON string', () => { const errorString = 'Not a JSON string'; diff --git a/libraries/react-native-iap/src/utils/error.ts b/libraries/react-native-iap/src/utils/error.ts index 688096d16..865215b8d 100644 --- a/libraries/react-native-iap/src/utils/error.ts +++ b/libraries/react-native-iap/src/utils/error.ts @@ -50,57 +50,32 @@ const extractBalancedJsonObject = ( value: string, startIndex: number, ): string | null => { - let depth = 0; - let isInsideString = false; - let isEscaped = false; - for (let index = startIndex; index < value.length; index += 1) { - const char = value[index]; - - if (isInsideString) { - if (isEscaped) { - isEscaped = false; - } else if (char === '\\') { - isEscaped = true; - } else if (char === '"') { - isInsideString = false; - } - continue; - } - - if (char === '\\' && value[index + 1] === '"') { - index += 1; + if (value[index] !== '}') { continue; } - if (char === '"') { - isInsideString = true; - } else if (char === '{') { - depth += 1; - } else if (char === '}') { - depth -= 1; - if (depth === 0) { - return value.substring(startIndex, index + 1); - } + const candidate = value.substring(startIndex, index + 1); + if (parseJsonPayload(candidate, value)) { + return candidate; } } return null; }; -const parseNSErrorJsonPayload = ( - rawString: string, -): IapError | null => { +const parseNSErrorJsonPayload = (rawString: string): IapError | null => { const payloads: string[] = []; const quotedPayloadMatch = /Code=-?\d+\s+"/.exec(rawString); if (quotedPayloadMatch?.index !== undefined) { - const contentStart = quotedPayloadMatch.index + quotedPayloadMatch[0].length; + const contentStart = + quotedPayloadMatch.index + quotedPayloadMatch[0].length; const userInfoIndex = rawString.indexOf('UserInfo=', contentStart); const contentEnd = userInfoIndex > contentStart ? rawString.lastIndexOf('"', userInfoIndex) - : -1; + : rawString.lastIndexOf('"'); if (contentEnd > contentStart) { payloads.push(rawString.substring(contentStart, contentEnd)); @@ -135,15 +110,18 @@ const parseNSErrorJsonPayload = ( }; const parseStructuredError = (error: Error): IapError | null => { - const errorWithCode = error as Error & {code?: unknown}; + const errorWithCode = error as Error & {code?: unknown} & Record< + string, + unknown + >; + + if (typeof errorWithCode.code === 'string' && errorWithCode.code.length > 0) { + const {code, message, ...extraFields} = errorWithCode; - if ( - typeof errorWithCode.code === 'string' && - errorWithCode.code.length > 0 - ) { return { - code: errorWithCode.code, - message: error.message, + ...extraFields, + code, + message: typeof message === 'string' ? message : error.message, }; } @@ -182,19 +160,9 @@ export function parseErrorStringToJsonObj( }; } - // Try to parse as JSON first - try { - const parsed = JSON.parse(errorString); - if (typeof parsed === 'object' && parsed !== null) { - // Ensure it has at least code and message - return { - code: parsed.code || ErrorCode.Unknown, - message: parsed.message || errorString, - ...parsed, - }; - } - } catch { - // Not JSON, continue with other formats + const jsonPayload = parseJsonPayload(errorString, errorString); + if (jsonPayload) { + return jsonPayload; } const nsErrorPayload = parseNSErrorJsonPayload(errorString); From 1b9b641f30bf9cfc9f4d22a88a1c423fae7c5a81 Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 23:42:28 +0900 Subject: [PATCH 5/7] fix(kmp): share iOS error mapping Use one ErrorCode resolver for iOS completion errors and listener dictionaries.\n\nAdd iosSimulatorArm64 coverage for NSError.userInfo mapping into PurchaseException so user-cancelled and structured fields remain preserved. --- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 58 ++++++++++--------- .../hyochan/kmpiap/IosErrorMappingTest.kt | 43 ++++++++++++++ 2 files changed, 75 insertions(+), 26 deletions(-) create mode 100644 libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt 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 a20559de1..9149ae226 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 @@ -18,6 +18,37 @@ import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import kotlin.coroutines.suspendCoroutine +internal fun errorCodeFromRawValue( + raw: String?, + fallback: ErrorCode = ErrorCode.Unknown +): ErrorCode = + raw?.let { runCatching { ErrorCode.fromJson(it) }.getOrNull() } ?: fallback + +// Completion handlers from the Swift bridge surface typed PurchaseError values +// through NSError.userInfo; use the same code resolver as listener dictionaries. +internal fun NSError.toPurchaseException( + fallbackCode: ErrorCode = ErrorCode.Unknown +): PurchaseException { + val code = errorCodeFromRawValue(stringFromUserInfo("code"), fallbackCode) + val message = stringFromUserInfo("message") + ?: localizedDescription.ifBlank { "iOS error" } + + return PurchaseException( + PurchaseError( + code = code, + message = message, + productId = stringFromUserInfo("productId"), + debugMessage = stringFromUserInfo("debugMessage") + ) + ) +} + +@OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) +internal fun NSError.stringFromUserInfo(key: String): String? { + val value = userInfo[key] ?: userInfo[NSString.create(string = key)] + return value?.toString()?.takeIf { it.isNotBlank() } +} + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) internal class InAppPurchaseIOS : KmpInAppPurchase { // OpenIAP module instance - use constructor, not shared() @@ -81,30 +112,6 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { private var promotedProductSubscription: NSObject? = null private var subscriptionBillingIssueSubscription: NSObject? = null - private fun NSError.toPurchaseException( - fallbackCode: ErrorCode = ErrorCode.Unknown - ): PurchaseException { - val code = stringFromUserInfo("code") - ?.let { raw -> runCatching { ErrorCode.fromJson(raw) }.getOrNull() } - ?: fallbackCode - val message = stringFromUserInfo("message") - ?: localizedDescription.ifBlank { "iOS error" } - - return PurchaseException( - PurchaseError( - code = code, - message = message, - productId = stringFromUserInfo("productId"), - debugMessage = stringFromUserInfo("debugMessage") - ) - ) - } - - private fun NSError.stringFromUserInfo(key: String): String? { - val value = userInfo[key] ?: userInfo[NSString.create(string = key)] - return value?.toString()?.takeIf { it.isNotBlank() } - } - init { // Register listeners setupListeners() @@ -128,8 +135,7 @@ internal class InAppPurchaseIOS : KmpInAppPurchase { // Purchase error listener errorSubscription = openIapModule.addPurchaseErrorListener { dictionary -> val map = (dictionary as? Map<*, *>)?.mapKeys { it.key.toString() } ?: return@addPurchaseErrorListener - val codeString = map["code"] as? String ?: "unknown" - val errorCode = ErrorCode.entries.find { it.rawValue == codeString } ?: ErrorCode.Unknown + val errorCode = errorCodeFromRawValue(map["code"] as? String) val error = PurchaseError( code = errorCode, message = map["message"] as? String ?: "", diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt new file mode 100644 index 000000000..6b1b312c2 --- /dev/null +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt @@ -0,0 +1,43 @@ +package io.github.hyochan.kmpiap + +import io.github.hyochan.kmpiap.openiap.ErrorCode +import kotlin.test.Test +import kotlin.test.assertEquals +import platform.Foundation.NSError + +class IosErrorMappingTest { + @Test + fun testNSErrorUserInfoMapsToPurchaseException() { + val error = NSError.errorWithDomain( + domain = "OpenIAP", + code = -1, + userInfo = mapOf( + "code" to "user-cancelled", + "message" to "Request Canceled", + "productId" to "premium_monthly", + "debugMessage" to "StoreKit cancellation" + ) + ) + + val exception = error.toPurchaseException() + + assertEquals(ErrorCode.UserCancelled, exception.error.code) + assertEquals("Request Canceled", exception.error.message) + assertEquals("premium_monthly", exception.error.productId) + assertEquals("StoreKit cancellation", exception.error.debugMessage) + } + + @Test + fun testNSErrorCodeFallback() { + val error = NSError.errorWithDomain( + domain = "OpenIAP", + code = -1, + userInfo = mapOf("message" to "Native failure") + ) + + val exception = error.toPurchaseException(ErrorCode.ServiceError) + + assertEquals(ErrorCode.ServiceError, exception.error.code) + assertEquals("Native failure", exception.error.message) + } +} From b34f3b8865e1ad46b15c9702eb60bf9444af317f Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 23:46:33 +0900 Subject: [PATCH 6/7] fix(rn): parse quoted iOS userInfo payloads Handle quoted NSLocalizedDescription JSON payloads in React Native NSError parsing.\n\nSimplify the KMP NSError userInfo lookup per review feedback while keeping the shared ErrorCode resolver and iOS test coverage intact. --- .../io/github/hyochan/kmpiap/InAppPurchaseIOS.kt | 2 +- .../src/__tests__/utils/error.test.ts | 14 ++++++++++++++ libraries/react-native-iap/src/utils/error.ts | 5 +++-- 3 files changed, 18 insertions(+), 3 deletions(-) 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 9149ae226..f88fa2a45 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 @@ -45,7 +45,7 @@ internal fun NSError.toPurchaseException( @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) internal fun NSError.stringFromUserInfo(key: String): String? { - val value = userInfo[key] ?: userInfo[NSString.create(string = key)] + val value = userInfo[key] return value?.toString()?.takeIf { it.isNotBlank() } } diff --git a/libraries/react-native-iap/src/__tests__/utils/error.test.ts b/libraries/react-native-iap/src/__tests__/utils/error.test.ts index f0fe81ea4..dfec0edf9 100644 --- a/libraries/react-native-iap/src/__tests__/utils/error.test.ts +++ b/libraries/react-native-iap/src/__tests__/utils/error.test.ts @@ -135,6 +135,20 @@ describe('Error utilities', () => { }); }); + it('should parse quoted localized description JSON', () => { + const error = new Error( + 'Error Domain=SKErrorDomain Code=-1 "The operation failed." ' + + 'UserInfo={NSLocalizedDescription="{\\"message\\":\\"Request Canceled\\",\\"code\\":\\"user-cancelled\\"}"}', + ); + + const result = parseErrorStringToJsonObj(error); + + expect(result).toEqual({ + code: ErrorCode.UserCancelled, + message: 'Request Canceled', + }); + }); + it('should handle non-JSON string', () => { const errorString = 'Not a JSON string'; diff --git a/libraries/react-native-iap/src/utils/error.ts b/libraries/react-native-iap/src/utils/error.ts index 865215b8d..41fabf5df 100644 --- a/libraries/react-native-iap/src/utils/error.ts +++ b/libraries/react-native-iap/src/utils/error.ts @@ -91,8 +91,9 @@ const parseNSErrorJsonPayload = (rawString: string): IapError | null => { valueStart += 1; } - if (rawString[valueStart] === '{') { - const payload = extractBalancedJsonObject(rawString, valueStart); + const firstBraceIndex = rawString.indexOf('{', valueStart); + if (firstBraceIndex >= 0 && firstBraceIndex <= valueStart + 2) { + const payload = extractBalancedJsonObject(rawString, firstBraceIndex); if (payload) { payloads.push(payload); } From 20636e3cacd1fad17b436d5b137c1e653c976c2a Mon Sep 17 00:00:00 2001 From: Hyo Date: Thu, 2 Jul 2026 23:56:50 +0900 Subject: [PATCH 7/7] fix(kmp): ignore null iOS error fields Treat NSNull values in NSError.userInfo as absent fields when building PurchaseException.\n\nExtend iOS error mapping tests to cover null debug metadata. --- .../github/hyochan/kmpiap/InAppPurchaseIOS.kt | 3 ++- .../hyochan/kmpiap/IosErrorMappingTest.kt | 21 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 f88fa2a45..70dcf285c 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 @@ -46,7 +46,8 @@ internal fun NSError.toPurchaseException( @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) internal fun NSError.stringFromUserInfo(key: String): String? { val value = userInfo[key] - return value?.toString()?.takeIf { it.isNotBlank() } + if (value == null || value is NSNull) return null + return value.toString().takeIf { it.isNotBlank() } } @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) diff --git a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt index 6b1b312c2..d28aad4ef 100644 --- a/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt @@ -3,7 +3,9 @@ package io.github.hyochan.kmpiap import io.github.hyochan.kmpiap.openiap.ErrorCode import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull import platform.Foundation.NSError +import platform.Foundation.NSNull class IosErrorMappingTest { @Test @@ -40,4 +42,23 @@ class IosErrorMappingTest { assertEquals(ErrorCode.ServiceError, exception.error.code) assertEquals("Native failure", exception.error.message) } + + @Test + fun testNSErrorUserInfoIgnoresNSNullValues() { + val error = NSError.errorWithDomain( + domain = "OpenIAP", + code = -1, + userInfo = mapOf( + "code" to "user-cancelled", + "message" to "Request Canceled", + "debugMessage" to NSNull() + ) + ) + + val exception = error.toPurchaseException() + + assertEquals(ErrorCode.UserCancelled, exception.error.code) + assertEquals("Request Canceled", exception.error.message) + assertNull(exception.error.debugMessage) + } }