From 044a708f051a33e83c1a4f6e03d5255c8eb61716 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 4 Mar 2026 18:29:04 +0900 Subject: [PATCH 1/3] fix(android): handle JNI exceptions during initConnection setup Wrap setActivity and listener registration in try-catch to convert raw JNI exceptions into structured OpenIapException with proper error code and message. Previously, exceptions from OpenIapModule lazy initialization or listener registration propagated unhandled through Promise.async, producing cryptic "Unknown N8facebook3jni12JniExceptionE error" messages on the JS side. Now developers receive structured errors with code "init-connection" and descriptive messages identifying the failure point. Closes #3144 Co-Authored-By: Claude Opus 4.6 --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 163 ++++++++++-------- 1 file changed, 94 insertions(+), 69 deletions(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 312b3cf5d..e7be0861d 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -100,19 +100,31 @@ class HybridRnIap : HybridRnIapSpec() { // CRITICAL: Set Activity BEFORE calling initConnection // Horizon SDK needs Activity to initialize OVRPlatform with proper returnComponent // https://github.com/meta-quest/Meta-Spatial-SDK-Samples/issues/82#issuecomment-3452577530 - withContext(Dispatchers.Main) { - runCatching { context.currentActivity } - .onSuccess { activity -> - if (activity != null) { - RnIapLog.debug("Activity available: ${activity.javaClass.name}") - openIap.setActivity(activity) - } else { - RnIapLog.warn("Activity is null during initConnection") + try { + withContext(Dispatchers.Main) { + runCatching { context.currentActivity } + .onSuccess { activity -> + if (activity != null) { + RnIapLog.debug("Activity available: ${activity.javaClass.name}") + openIap.setActivity(activity) + } else { + RnIapLog.warn("Activity is null during initConnection") + } } - } - .onFailure { - RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context") - } + .onFailure { + RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context") + } + } + } catch (err: Throwable) { + val error = OpenIAPError.InitConnection + RnIapLog.failure("initConnection.setActivity", err) + throw OpenIapException( + toErrorJson( + error = error, + debugMessage = err.message ?: err.javaClass.name, + messageOverride = "Failed to set activity: ${err.message ?: err.javaClass.name}" + ) + ) } // Single-flight: capture or create the shared Deferred atomically @@ -128,64 +140,77 @@ class HybridRnIap : HybridRnIapSpec() { return@async result } - if (!listenersAttached) { - listenersAttached = true - RnIapLog.payload("listeners.attach", null) - openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p -> - runCatching { - RnIapLog.result( - "purchaseUpdatedListener", - mapOf("id" to p.id, "sku" to p.productId) - ) - sendPurchaseUpdate(convertToNitroPurchase(p)) - }.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) } - }) - openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e -> - val code = OpenIAPError.toCode(e) - val message = e.message ?: OpenIAPError.defaultMessage(code) - runCatching { - RnIapLog.result( - "purchaseErrorListener", - mapOf("code" to code, "message" to message) - ) - sendPurchaseError( - NitroPurchaseResult( - responseCode = -1.0, - debugMessage = null, - code = code, - message = message, - purchaseToken = null + try { + if (!listenersAttached) { + listenersAttached = true + RnIapLog.payload("listeners.attach", null) + openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p -> + runCatching { + RnIapLog.result( + "purchaseUpdatedListener", + mapOf("id" to p.id, "sku" to p.productId) ) - ) - }.onFailure { RnIapLog.failure("purchaseErrorListener", it) } - }) - openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details -> - runCatching { - RnIapLog.result( - "userChoiceBillingListener", - mapOf("products" to details.products, "token" to details.externalTransactionToken) - ) - val nitroDetails = UserChoiceBillingDetails( - externalTransactionToken = details.externalTransactionToken, - products = details.products.toTypedArray() - ) - sendUserChoiceBilling(nitroDetails) - }.onFailure { RnIapLog.failure("userChoiceBillingListener", it) } - }) - // Developer Provided Billing listener (External Payments - 8.3.0+) - openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details -> - runCatching { - RnIapLog.result( - "developerProvidedBillingListener", - mapOf("token" to details.externalTransactionToken) - ) - val nitroDetails = DeveloperProvidedBillingDetailsAndroid( - externalTransactionToken = details.externalTransactionToken - ) - sendDeveloperProvidedBilling(nitroDetails) - }.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) } - }) - RnIapLog.result("listeners.attach", "attached") + sendPurchaseUpdate(convertToNitroPurchase(p)) + }.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) } + }) + openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e -> + val code = OpenIAPError.toCode(e) + val message = e.message ?: OpenIAPError.defaultMessage(code) + runCatching { + RnIapLog.result( + "purchaseErrorListener", + mapOf("code" to code, "message" to message) + ) + sendPurchaseError( + NitroPurchaseResult( + responseCode = -1.0, + debugMessage = null, + code = code, + message = message, + purchaseToken = null + ) + ) + }.onFailure { RnIapLog.failure("purchaseErrorListener", it) } + }) + openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details -> + runCatching { + RnIapLog.result( + "userChoiceBillingListener", + mapOf("products" to details.products, "token" to details.externalTransactionToken) + ) + val nitroDetails = UserChoiceBillingDetails( + externalTransactionToken = details.externalTransactionToken, + products = details.products.toTypedArray() + ) + sendUserChoiceBilling(nitroDetails) + }.onFailure { RnIapLog.failure("userChoiceBillingListener", it) } + }) + // Developer Provided Billing listener (External Payments - 8.3.0+) + openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details -> + runCatching { + RnIapLog.result( + "developerProvidedBillingListener", + mapOf("token" to details.externalTransactionToken) + ) + val nitroDetails = DeveloperProvidedBillingDetailsAndroid( + externalTransactionToken = details.externalTransactionToken + ) + sendDeveloperProvidedBilling(nitroDetails) + }.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) } + }) + RnIapLog.result("listeners.attach", "attached") + } + } catch (err: Throwable) { + listenersAttached = false + val error = OpenIAPError.InitConnection + RnIapLog.failure("initConnection.listeners", err) + throw OpenIapException( + toErrorJson( + error = error, + debugMessage = err.message ?: err.javaClass.name, + messageOverride = "Failed to register billing listeners: ${err.message ?: err.javaClass.name}" + ) + ) } // We created it above; reuse the shared instance From 6359a7a5fc1995f56d541a0db89a0b55512ee9b2 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 4 Mar 2026 18:46:37 +0900 Subject: [PATCH 2/3] fix: rethrow CancellationException and extract error message variable - Add dedicated CancellationException catch blocks before Throwable to preserve coroutine cancellation semantics in both setActivity and listeners try-catch blocks - Extract `err.message ?: err.javaClass.name` into `errorMessage` variable to avoid duplicate expressions Co-Authored-By: Claude Opus 4.6 --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index e7be0861d..44426d1ce 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -42,6 +42,7 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener import dev.hyo.openiap.store.OpenIapStore +import kotlin.coroutines.cancellation.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.CompletableDeferred @@ -115,14 +116,17 @@ class HybridRnIap : HybridRnIapSpec() { RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context") } } + } catch (err: CancellationException) { + throw err } catch (err: Throwable) { val error = OpenIAPError.InitConnection + val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.setActivity", err) throw OpenIapException( toErrorJson( error = error, - debugMessage = err.message ?: err.javaClass.name, - messageOverride = "Failed to set activity: ${err.message ?: err.javaClass.name}" + debugMessage = errorMessage, + messageOverride = "Failed to set activity: $errorMessage" ) ) } @@ -200,15 +204,18 @@ class HybridRnIap : HybridRnIapSpec() { }) RnIapLog.result("listeners.attach", "attached") } + } catch (err: CancellationException) { + throw err } catch (err: Throwable) { listenersAttached = false val error = OpenIAPError.InitConnection + val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.listeners", err) throw OpenIapException( toErrorJson( error = error, - debugMessage = err.message ?: err.javaClass.name, - messageOverride = "Failed to register billing listeners: ${err.message ?: err.javaClass.name}" + debugMessage = errorMessage, + messageOverride = "Failed to register billing listeners: $errorMessage" ) ) } From 522fc4f792d43dea6c983b3880a31212719ee328 Mon Sep 17 00:00:00 2001 From: hyochan Date: Wed, 4 Mar 2026 18:53:28 +0900 Subject: [PATCH 3/3] fix(android): complete initDeferred on listener registration failure If listener registration throws, complete initDeferred exceptionally and reset it to prevent concurrent initConnection callers from deadlocking on await(). Co-Authored-By: Claude Opus 4.6 --- .../src/main/java/com/margelo/nitro/iap/HybridRnIap.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt index 44426d1ce..6f31a7a39 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -211,13 +211,21 @@ class HybridRnIap : HybridRnIapSpec() { val error = OpenIAPError.InitConnection val errorMessage = err.message ?: err.javaClass.name RnIapLog.failure("initConnection.listeners", err) - throw OpenIapException( + val wrapped = OpenIapException( toErrorJson( error = error, debugMessage = errorMessage, messageOverride = "Failed to register billing listeners: $errorMessage" ) ) + synchronized(initLock) { + initDeferred?.let { deferred -> + if (!deferred.isCompleted) deferred.completeExceptionally(wrapped) + } + initDeferred = null + } + isInitialized = false + throw wrapped } // We created it above; reuse the shared instance