From de332d05bf5953d5e88991415a711781afca5250 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 19 Feb 2026 01:02:36 +0900 Subject: [PATCH 1/3] fix(android,ios): add thread safety to listener operations - 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 Co-Authored-By: Claude Opus 4.6 --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 34 +++++++++++-------- ios/HybridRnIap.swift | 10 ++++-- 2 files changed, 28 insertions(+), 16 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 2ffec2352..2d576c621 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -731,21 +731,27 @@ class HybridRnIap : HybridRnIapSpec() { // Event listener methods override fun addPurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) { - purchaseUpdatedListeners.add(listener) + synchronized(purchaseUpdatedListeners) { + purchaseUpdatedListeners.add(listener) + } } - + override fun addPurchaseErrorListener(listener: (error: NitroPurchaseResult) -> Unit) { - purchaseErrorListeners.add(listener) + synchronized(purchaseErrorListeners) { + purchaseErrorListeners.add(listener) + } } - + override fun removePurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) { - // Note: Kotlin doesn't have easy closure comparison, so we'll clear all listeners - purchaseUpdatedListeners.clear() + synchronized(purchaseUpdatedListeners) { + purchaseUpdatedListeners.clear() + } } - + override fun removePurchaseErrorListener(listener: (error: NitroPurchaseResult) -> Unit) { - // Note: Kotlin doesn't have easy closure comparison, so we'll clear all listeners - purchaseErrorListeners.clear() + synchronized(purchaseErrorListeners) { + purchaseErrorListeners.clear() + } } override fun addPromotedProductListenerIOS(listener: (product: NitroProduct) -> Unit) { @@ -773,11 +779,11 @@ class HybridRnIap : HybridRnIapSpec() { "sendPurchaseUpdate", mapOf("productId" to purchase.productId, "platform" to purchase.platform) ) - for (listener in purchaseUpdatedListeners) { - listener(purchase) + synchronized(purchaseUpdatedListeners) { + purchaseUpdatedListeners.forEach { it(purchase) } } } - + /** * Send purchase error event to listeners */ @@ -786,8 +792,8 @@ class HybridRnIap : HybridRnIapSpec() { "sendPurchaseError", mapOf("code" to error.code, "message" to error.message) ) - for (listener in purchaseErrorListeners) { - listener(error) + synchronized(purchaseErrorListeners) { + purchaseErrorListeners.forEach { it(error) } } } diff --git a/ios/HybridRnIap.swift b/ios/HybridRnIap.swift index 64cfc7516..ed88b12a8 100644 --- a/ios/HybridRnIap.swift +++ b/ios/HybridRnIap.swift @@ -899,7 +899,10 @@ class HybridRnIap: HybridRnIapSpec { if purchaseUpdatedSub == nil { RnIapLog.payload("purchaseUpdatedListener.register", nil) purchaseUpdatedSub = OpenIapModule.shared.purchaseUpdatedListener { [weak self] openIapPurchase in - guard let self else { return } + guard let self else { + RnIapLog.warn("purchaseUpdatedListener: HybridRnIap deallocated, purchase event dropped") + return + } Task { @MainActor in let rawPayload = OpenIapSerialization.purchase(openIapPurchase) let payload = RnIapHelper.sanitizeDictionary(rawPayload) @@ -917,7 +920,10 @@ class HybridRnIap: HybridRnIapSpec { if purchaseErrorSub == nil { RnIapLog.payload("purchaseErrorListener.register", nil) purchaseErrorSub = OpenIapModule.shared.purchaseErrorListener { [weak self] error in - guard let self else { return } + guard let self else { + RnIapLog.warn("purchaseErrorListener: HybridRnIap deallocated, error event dropped") + return + } Task { @MainActor in let payload = RnIapHelper.sanitizeDictionary(OpenIapSerialization.encode(error)) RnIapLog.result("purchaseErrorListener", payload) From 19781c176213c378c49d20a7f2cdc7c7b53d42dc Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 19 Feb 2026 01:12:18 +0900 Subject: [PATCH 2/3] ci: increase validate-ios timeout to 60 minutes Co-Authored-By: Claude Opus 4.6 --- .github/workflows/deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 95245dd81..a757b8139 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -62,7 +62,7 @@ jobs: validate-ios: runs-on: macos-15 - timeout-minutes: 30 + timeout-minutes: 60 env: XCODE_VERSION: 16.4 steps: From a7ae01d1ae983d6bc3ed266b7a0bdcc4513ff825 Mon Sep 17 00:00:00 2001 From: hyochan Date: Thu, 19 Feb 2026 01:23:47 +0900 Subject: [PATCH 3/3] fix(android,ios): address PR review feedback Android: - Use remove(listener) instead of clear() for targeted removal - Use snapshot-before-iterate pattern to prevent reentrance issues - Add synchronized blocks to endConnection() clear() calls iOS: - Add warning log to promotedProductSub guard for consistency --- .../java/com/margelo/nitro/iap/HybridRnIap.kt | 18 ++++++++---------- ios/HybridRnIap.swift | 5 ++++- 2 files changed, 12 insertions(+), 11 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 2d576c621..96be93784 100644 --- a/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt +++ b/android/src/main/java/com/margelo/nitro/iap/HybridRnIap.kt @@ -255,8 +255,8 @@ class HybridRnIap : HybridRnIapSpec() { productTypeBySku.clear() isInitialized = false listenersAttached = false - purchaseUpdatedListeners.clear() - purchaseErrorListeners.clear() + synchronized(purchaseUpdatedListeners) { purchaseUpdatedListeners.clear() } + synchronized(purchaseErrorListeners) { purchaseErrorListeners.clear() } promotedProductListenersIOS.clear() userChoiceBillingListenersAndroid.clear() developerProvidedBillingListenersAndroid.clear() @@ -744,13 +744,13 @@ class HybridRnIap : HybridRnIapSpec() { override fun removePurchaseUpdatedListener(listener: (purchase: NitroPurchase) -> Unit) { synchronized(purchaseUpdatedListeners) { - purchaseUpdatedListeners.clear() + purchaseUpdatedListeners.remove(listener) } } override fun removePurchaseErrorListener(listener: (error: NitroPurchaseResult) -> Unit) { synchronized(purchaseErrorListeners) { - purchaseErrorListeners.clear() + purchaseErrorListeners.remove(listener) } } @@ -779,9 +779,8 @@ class HybridRnIap : HybridRnIapSpec() { "sendPurchaseUpdate", mapOf("productId" to purchase.productId, "platform" to purchase.platform) ) - synchronized(purchaseUpdatedListeners) { - purchaseUpdatedListeners.forEach { it(purchase) } - } + val snapshot = synchronized(purchaseUpdatedListeners) { ArrayList(purchaseUpdatedListeners) } + snapshot.forEach { it(purchase) } } /** @@ -792,9 +791,8 @@ class HybridRnIap : HybridRnIapSpec() { "sendPurchaseError", mapOf("code" to error.code, "message" to error.message) ) - synchronized(purchaseErrorListeners) { - purchaseErrorListeners.forEach { it(error) } - } + val snapshot = synchronized(purchaseErrorListeners) { ArrayList(purchaseErrorListeners) } + snapshot.forEach { it(error) } } /** diff --git a/ios/HybridRnIap.swift b/ios/HybridRnIap.swift index ed88b12a8..af243cde2 100644 --- a/ios/HybridRnIap.swift +++ b/ios/HybridRnIap.swift @@ -941,7 +941,10 @@ class HybridRnIap: HybridRnIapSpec { if promotedProductSub == nil { RnIapLog.payload("promotedProductListenerIOS.register", nil) promotedProductSub = OpenIapModule.shared.promotedProductListenerIOS { [weak self] productId in - guard let self else { return } + guard let self else { + RnIapLog.warn("promotedProductListenerIOS: HybridRnIap deallocated, promoted product event dropped") + return + } Task { RnIapLog.payload("promotedProductListenerIOS", ["productId": productId]) do {