Skip to content
This repository was archived by the owner on Apr 26, 2026. It is now read-only.

Commit f7ff308

Browse files
hyochanclaude
andauthored
fix(android): handle JNI exceptions during initConnection setup (#3158)
## Summary - Wrap `setActivity` and listener registration in try-catch blocks to convert raw JNI exceptions into structured `OpenIapException` - Prevents cryptic `Unknown N8facebook3jni12JniExceptionE error` messages on the JS side - Developers now receive structured errors with code `init-connection` and descriptive messages ## Problem During `initConnection`, the `openIap` object is lazy-initialized (`OpenIapModule(context)`). The first access happens at either `openIap.setActivity()` or `openIap.addPurchaseUpdateListener()` — both were **outside any try-catch block**. On devices without Google Play Services or with billing client issues, the constructor throws a JNI exception that propagates unhandled through `Promise.async`. Nitro converts the raw C++ exception class name (`facebook::jni::JniException`) into the unhelpful error message users see. Meanwhile, the actual `openIap.initConnection()` call (which IS protected by try-catch) is never reached because the exception occurs earlier. ## Changes ### Android (`android/.../HybridRnIap.kt`) - Wrap `setActivity` call in try-catch → throws `OpenIapException` with message `"Failed to set activity: <original error>"` - Wrap listener registration block in try-catch → throws `OpenIapException` with message `"Failed to register billing listeners: <original error>"` - Reset `listenersAttached = false` on listener registration failure for retry on next `initConnection` call - All converted errors use `init-connection` error code for consistent JS-side handling ## Test plan - [x] `yarn typecheck` passes - [x] `yarn lint` passes - [x] `yarn test` passes (251 tests, 12 suites) - [x] Pre-commit hooks pass Closes #3144 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Strengthened in-app purchase connection error handling with clearer diagnostics and logging for more reliable failure visibility. * Improved billing listener registration resilience: failures now roll back listener state, cancel pending initialization, and propagate errors to concurrent callers to avoid silent failures and improve purchase flow stability. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent f14d652 commit f7ff308

1 file changed

Lines changed: 109 additions & 69 deletions

File tree

android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt

Lines changed: 109 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import dev.hyo.openiap.ExternalLinkLaunchModeAndroid as OpenIapExternalLinkLaunc
4242
import dev.hyo.openiap.ExternalLinkTypeAndroid as OpenIapExternalLinkType
4343
import dev.hyo.openiap.listener.OpenIapDeveloperProvidedBillingListener
4444
import dev.hyo.openiap.store.OpenIapStore
45+
import kotlin.coroutines.cancellation.CancellationException
4546
import kotlinx.coroutines.Dispatchers
4647
import kotlinx.coroutines.withContext
4748
import kotlinx.coroutines.CompletableDeferred
@@ -100,19 +101,34 @@ class HybridRnIap : HybridRnIapSpec() {
100101
// CRITICAL: Set Activity BEFORE calling initConnection
101102
// Horizon SDK needs Activity to initialize OVRPlatform with proper returnComponent
102103
// https://github.com/meta-quest/Meta-Spatial-SDK-Samples/issues/82#issuecomment-3452577530
103-
withContext(Dispatchers.Main) {
104-
runCatching { context.currentActivity }
105-
.onSuccess { activity ->
106-
if (activity != null) {
107-
RnIapLog.debug("Activity available: ${activity.javaClass.name}")
108-
openIap.setActivity(activity)
109-
} else {
110-
RnIapLog.warn("Activity is null during initConnection")
104+
try {
105+
withContext(Dispatchers.Main) {
106+
runCatching { context.currentActivity }
107+
.onSuccess { activity ->
108+
if (activity != null) {
109+
RnIapLog.debug("Activity available: ${activity.javaClass.name}")
110+
openIap.setActivity(activity)
111+
} else {
112+
RnIapLog.warn("Activity is null during initConnection")
113+
}
111114
}
112-
}
113-
.onFailure {
114-
RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context")
115-
}
115+
.onFailure {
116+
RnIapLog.warn("Activity not available during initConnection - OpenIAP will use Context")
117+
}
118+
}
119+
} catch (err: CancellationException) {
120+
throw err
121+
} catch (err: Throwable) {
122+
val error = OpenIAPError.InitConnection
123+
val errorMessage = err.message ?: err.javaClass.name
124+
RnIapLog.failure("initConnection.setActivity", err)
125+
throw OpenIapException(
126+
toErrorJson(
127+
error = error,
128+
debugMessage = errorMessage,
129+
messageOverride = "Failed to set activity: $errorMessage"
130+
)
131+
)
116132
}
117133

118134
// Single-flight: capture or create the shared Deferred atomically
@@ -128,64 +144,88 @@ class HybridRnIap : HybridRnIapSpec() {
128144
return@async result
129145
}
130146

131-
if (!listenersAttached) {
132-
listenersAttached = true
133-
RnIapLog.payload("listeners.attach", null)
134-
openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
135-
runCatching {
136-
RnIapLog.result(
137-
"purchaseUpdatedListener",
138-
mapOf("id" to p.id, "sku" to p.productId)
139-
)
140-
sendPurchaseUpdate(convertToNitroPurchase(p))
141-
}.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) }
142-
})
143-
openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
144-
val code = OpenIAPError.toCode(e)
145-
val message = e.message ?: OpenIAPError.defaultMessage(code)
146-
runCatching {
147-
RnIapLog.result(
148-
"purchaseErrorListener",
149-
mapOf("code" to code, "message" to message)
150-
)
151-
sendPurchaseError(
152-
NitroPurchaseResult(
153-
responseCode = -1.0,
154-
debugMessage = null,
155-
code = code,
156-
message = message,
157-
purchaseToken = null
147+
try {
148+
if (!listenersAttached) {
149+
listenersAttached = true
150+
RnIapLog.payload("listeners.attach", null)
151+
openIap.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { p ->
152+
runCatching {
153+
RnIapLog.result(
154+
"purchaseUpdatedListener",
155+
mapOf("id" to p.id, "sku" to p.productId)
158156
)
159-
)
160-
}.onFailure { RnIapLog.failure("purchaseErrorListener", it) }
161-
})
162-
openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details ->
163-
runCatching {
164-
RnIapLog.result(
165-
"userChoiceBillingListener",
166-
mapOf("products" to details.products, "token" to details.externalTransactionToken)
167-
)
168-
val nitroDetails = UserChoiceBillingDetails(
169-
externalTransactionToken = details.externalTransactionToken,
170-
products = details.products.toTypedArray()
171-
)
172-
sendUserChoiceBilling(nitroDetails)
173-
}.onFailure { RnIapLog.failure("userChoiceBillingListener", it) }
174-
})
175-
// Developer Provided Billing listener (External Payments - 8.3.0+)
176-
openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details ->
177-
runCatching {
178-
RnIapLog.result(
179-
"developerProvidedBillingListener",
180-
mapOf("token" to details.externalTransactionToken)
181-
)
182-
val nitroDetails = DeveloperProvidedBillingDetailsAndroid(
183-
externalTransactionToken = details.externalTransactionToken
184-
)
185-
sendDeveloperProvidedBilling(nitroDetails)
186-
}.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) }
187-
})
188-
RnIapLog.result("listeners.attach", "attached")
157+
sendPurchaseUpdate(convertToNitroPurchase(p))
158+
}.onFailure { RnIapLog.failure("purchaseUpdatedListener", it) }
159+
})
160+
openIap.addPurchaseErrorListener(OpenIapPurchaseErrorListener { e ->
161+
val code = OpenIAPError.toCode(e)
162+
val message = e.message ?: OpenIAPError.defaultMessage(code)
163+
runCatching {
164+
RnIapLog.result(
165+
"purchaseErrorListener",
166+
mapOf("code" to code, "message" to message)
167+
)
168+
sendPurchaseError(
169+
NitroPurchaseResult(
170+
responseCode = -1.0,
171+
debugMessage = null,
172+
code = code,
173+
message = message,
174+
purchaseToken = null
175+
)
176+
)
177+
}.onFailure { RnIapLog.failure("purchaseErrorListener", it) }
178+
})
179+
openIap.addUserChoiceBillingListener(OpenIapUserChoiceBillingListener { details ->
180+
runCatching {
181+
RnIapLog.result(
182+
"userChoiceBillingListener",
183+
mapOf("products" to details.products, "token" to details.externalTransactionToken)
184+
)
185+
val nitroDetails = UserChoiceBillingDetails(
186+
externalTransactionToken = details.externalTransactionToken,
187+
products = details.products.toTypedArray()
188+
)
189+
sendUserChoiceBilling(nitroDetails)
190+
}.onFailure { RnIapLog.failure("userChoiceBillingListener", it) }
191+
})
192+
// Developer Provided Billing listener (External Payments - 8.3.0+)
193+
openIap.addDeveloperProvidedBillingListener(OpenIapDeveloperProvidedBillingListener { details ->
194+
runCatching {
195+
RnIapLog.result(
196+
"developerProvidedBillingListener",
197+
mapOf("token" to details.externalTransactionToken)
198+
)
199+
val nitroDetails = DeveloperProvidedBillingDetailsAndroid(
200+
externalTransactionToken = details.externalTransactionToken
201+
)
202+
sendDeveloperProvidedBilling(nitroDetails)
203+
}.onFailure { RnIapLog.failure("developerProvidedBillingListener", it) }
204+
})
205+
RnIapLog.result("listeners.attach", "attached")
206+
}
207+
} catch (err: CancellationException) {
208+
throw err
209+
} catch (err: Throwable) {
210+
listenersAttached = false
211+
val error = OpenIAPError.InitConnection
212+
val errorMessage = err.message ?: err.javaClass.name
213+
RnIapLog.failure("initConnection.listeners", err)
214+
val wrapped = OpenIapException(
215+
toErrorJson(
216+
error = error,
217+
debugMessage = errorMessage,
218+
messageOverride = "Failed to register billing listeners: $errorMessage"
219+
)
220+
)
221+
synchronized(initLock) {
222+
initDeferred?.let { deferred ->
223+
if (!deferred.isCompleted) deferred.completeExceptionally(wrapped)
224+
}
225+
initDeferred = null
226+
}
227+
isInitialized = false
228+
throw wrapped
189229
}
190230

191231
// We created it above; reuse the shared instance

0 commit comments

Comments
 (0)