Skip to content

Commit e130706

Browse files
hyochanclaude
andauthored
fix(android,ios): add thread safety to listener operations (#3152)
## Summary - Android: wrap listener add/remove/iterate in `synchronized` blocks to prevent `ConcurrentModificationException` - iOS: add warning logs when `HybridRnIap` is deallocated during purchase/error listener callbacks for better diagnostics ## Changes ### Android (`HybridRnIap.kt`) - `addPurchaseUpdatedListener`, `removePurchaseUpdatedListener`, `addPurchaseErrorListener`, `removePurchaseErrorListener` now use `synchronized` blocks - `sendPurchaseUpdate` and `sendPurchaseError` iterate listeners within `synchronized` blocks - Prevents race conditions when listeners are modified from one thread while being iterated on another ### iOS (`HybridRnIap.swift`) - Added `RnIapLog.warn` messages when `self` is deallocated (`nil`) in OpenIAP purchase and error listener callbacks - Helps diagnose cases where purchase events are dropped due to `HybridRnIap` lifecycle issues (related to #3150) ## Context Investigating #3150 (purchases not being notified) revealed a potential thread safety issue in listener management on Android, and a diagnostics gap on iOS where purchase events could be silently dropped if the native module was deallocated. ## Test plan - [x] `yarn typecheck` passes - [x] `yarn lint` passes - [x] Pre-commit hooks pass 🤖 Generated with [Claude Code](https://claude.ai/code) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Bug Fixes** * Improved thread-safety for in-app purchase listener registration and event dispatch on Android to prevent race conditions. * Added guards and clearer logging for in-app purchase event handling on iOS so events are dropped with warnings if the handler is no longer available. * **Chores** * Increased CI/CD deployment validation timeout to allow longer-running checks. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 6343b15 commit e130706

File tree

3 files changed

+35
-22
lines changed

3 files changed

+35
-22
lines changed

.github/workflows/deploy.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ jobs:
6262

6363
validate-ios:
6464
runs-on: macos-15
65-
timeout-minutes: 30
65+
timeout-minutes: 60
6666
env:
6767
XCODE_VERSION: 16.4
6868
steps:

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

Lines changed: 22 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -255,8 +255,8 @@ class HybridRnIap : HybridRnIapSpec() {
255255
productTypeBySku.clear()
256256
isInitialized = false
257257
listenersAttached = false
258-
purchaseUpdatedListeners.clear()
259-
purchaseErrorListeners.clear()
258+
synchronized(purchaseUpdatedListeners) { purchaseUpdatedListeners.clear() }
259+
synchronized(purchaseErrorListeners) { purchaseErrorListeners.clear() }
260260
promotedProductListenersIOS.clear()
261261
userChoiceBillingListenersAndroid.clear()
262262
developerProvidedBillingListenersAndroid.clear()
@@ -731,21 +731,27 @@ class HybridRnIap : HybridRnIapSpec() {
731731

732732
// Event listener methods
733733
override fun addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) {
734-
purchaseUpdatedListeners.add(listener)
734+
synchronized(purchaseUpdatedListeners) {
735+
purchaseUpdatedListeners.add(listener)
736+
}
735737
}
736-
738+
737739
override fun addPurchaseErrorListener(listener: (error: NitroPurchaseResult) -> Unit) {
738-
purchaseErrorListeners.add(listener)
740+
synchronized(purchaseErrorListeners) {
741+
purchaseErrorListeners.add(listener)
742+
}
739743
}
740-
744+
741745
override fun removePurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) {
742-
// Note: Kotlin doesn't have easy closure comparison, so we'll clear all listeners
743-
purchaseUpdatedListeners.clear()
746+
synchronized(purchaseUpdatedListeners) {
747+
purchaseUpdatedListeners.remove(listener)
748+
}
744749
}
745-
750+
746751
override fun removePurchaseErrorListener(listener: (error: NitroPurchaseResult) -> Unit) {
747-
// Note: Kotlin doesn't have easy closure comparison, so we'll clear all listeners
748-
purchaseErrorListeners.clear()
752+
synchronized(purchaseErrorListeners) {
753+
purchaseErrorListeners.remove(listener)
754+
}
749755
}
750756

751757
override fun addPromotedProductListenerIOS(listener: (product: NitroProduct) -> Unit) {
@@ -773,11 +779,10 @@ class HybridRnIap : HybridRnIapSpec() {
773779
"sendPurchaseUpdate",
774780
mapOf("productId" to purchase.productId, "platform" to purchase.platform)
775781
)
776-
for (listener in purchaseUpdatedListeners) {
777-
listener(purchase)
778-
}
782+
val snapshot = synchronized(purchaseUpdatedListeners) { ArrayList(purchaseUpdatedListeners) }
783+
snapshot.forEach { it(purchase) }
779784
}
780-
785+
781786
/**
782787
* Send purchase error event to listeners
783788
*/
@@ -786,9 +791,8 @@ class HybridRnIap : HybridRnIapSpec() {
786791
"sendPurchaseError",
787792
mapOf("code" to error.code, "message" to error.message)
788793
)
789-
for (listener in purchaseErrorListeners) {
790-
listener(error)
791-
}
794+
val snapshot = synchronized(purchaseErrorListeners) { ArrayList(purchaseErrorListeners) }
795+
snapshot.forEach { it(error) }
792796
}
793797

794798
/**

ios/HybridRnIap.swift

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -899,7 +899,10 @@ class HybridRnIap: HybridRnIapSpec {
899899
if purchaseUpdatedSub == nil {
900900
RnIapLog.payload("purchaseUpdatedListener.register", nil)
901901
purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in
902-
guard let self else { return }
902+
guard let self else {
903+
RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped")
904+
return
905+
}
903906
Task { @MainActor in
904907
let rawPayload = OpenIapSerialization.purchase(openIapPurchase)
905908
let payload = RnIapHelper.sanitizeDictionary(rawPayload)
@@ -917,7 +920,10 @@ class HybridRnIap: HybridRnIapSpec {
917920
if purchaseErrorSub == nil {
918921
RnIapLog.payload("purchaseErrorListener.register", nil)
919922
purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in
920-
guard let self else { return }
923+
guard let self else {
924+
RnIapLog.warn("purchaseErrorListener: HybridRnIap deallocated, error event dropped")
925+
return
926+
}
921927
Task { @MainActor in
922928
let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error))
923929
RnIapLog.result("purchaseErrorListener", payload)
@@ -935,7 +941,10 @@ class HybridRnIap: HybridRnIapSpec {
935941
if promotedProductSub == nil {
936942
RnIapLog.payload("promotedProductListenerIOS.register", nil)
937943
promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in
938-
guard let self else { return }
944+
guard let self else {
945+
RnIapLog.warn("promotedProductListenerIOS: HybridRnIap deallocated, promoted product event dropped")
946+
return
947+
}
939948
Task {
940949
RnIapLog.payload("promotedProductListenerIOS", ["productId": productId])
941950
do {

0 commit comments

Comments
 (0)