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..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 @@ -18,6 +18,38 @@ 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] + if (value == null || value is NSNull) return null + return value.toString().takeIf { it.isNotBlank() } +} + @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) internal class InAppPurchaseIOS : KmpInAppPurchase { // OpenIAP module instance - use constructor, not shared() @@ -104,8 +136,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 ?: "", @@ -162,7 +193,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 +226,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 +259,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 +314,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 +329,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 +360,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 +376,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 +392,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 +408,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 +423,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 +439,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 +457,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 +497,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 +539,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 +582,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 +601,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 +634,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 +653,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 +672,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 +698,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 +716,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 +734,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 +745,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 +783,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 +800,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 +819,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 +854,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 +887,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 +904,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 +922,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 +958,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 } 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..d28aad4ef --- /dev/null +++ b/libraries/kmp-iap/library/src/iosTest/kotlin/io/github/hyochan/kmpiap/IosErrorMappingTest.kt @@ -0,0 +1,64 @@ +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 + 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) + } + + @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) + } +} 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..dfec0edf9 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,108 @@ 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 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 ' + + '"{\\"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 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." ' + + 'UserInfo={NSLocalizedDescription={\\"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 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 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/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..41fabf5df 100644 --- a/libraries/react-native-iap/src/utils/error.ts +++ b/libraries/react-native-iap/src/utils/error.ts @@ -16,12 +16,125 @@ 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 => { + for (let index = startIndex; index < value.length; index += 1) { + if (value[index] !== '}') { + continue; + } + + const candidate = value.substring(startIndex, index + 1); + if (parseJsonPayload(candidate, value)) { + return candidate; + } + } + + 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) + : rawString.lastIndexOf('"'); + + 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; + } + + const firstBraceIndex = rawString.indexOf('{', valueStart); + if (firstBraceIndex >= 0 && firstBraceIndex <= valueStart + 2) { + const payload = extractBalancedJsonObject(rawString, firstBraceIndex); + 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} & Record< + string, + unknown + >; + + if (typeof errorWithCode.code === 'string' && errorWithCode.code.length > 0) { + const {code, message, ...extraFields} = errorWithCode; + + return { + ...extraFields, + code, + message: typeof message === 'string' ? 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 +145,11 @@ export function parseErrorStringToJsonObj( ): IapError { // Handle Error objects if (errorString instanceof Error) { + const structuredError = parseStructuredError(errorString); + if (structuredError) { + return structuredError; + } + errorString = errorString.message; } @@ -43,19 +161,14 @@ 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); + if (nsErrorPayload) { + return nsErrorPayload; } // Try to parse "CODE: message" format 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',