diff --git a/.claude/commands/resolve-issue.md b/.claude/commands/resolve-issue.md index c43febb1..4e80db13 100644 --- a/.claude/commands/resolve-issue.md +++ b/.claude/commands/resolve-issue.md @@ -130,6 +130,18 @@ EOF )" ``` +#### 4e. Add labels to the PR + +Mirror the same labels you applied to the issue onto the PR so the dashboard views stay consistent. Use the same label selection guide from Step 2. + +> **Note:** `gh pr edit --add-label` may fail with a `Projects (classic)` GraphQL error on this repo. Use the REST API directly instead (works reliably since PRs are issues on GitHub): + +```bash +gh api -X POST repos/hyodotdev/openiap/issues//labels \ + -f "labels[]=" \ + -f "labels[]=" +``` + ### 5. Comment on the Issue Always comment on the issue with your findings: diff --git a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt index dc6f8da0..70c3eccc 100644 --- a/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/horizon/java/dev/hyo/openiap/OpenIapModule.kt @@ -49,6 +49,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicReference import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException @@ -97,7 +98,16 @@ class OpenIapModule( private var billingClient: BillingClient? = null private var currentActivityRef: WeakReference? = null - private var currentPurchaseCallback: ((Result>) -> Unit)? = null + private val currentPurchaseCallback = AtomicReference<((Result>) -> Unit)?>(null) + + /** + * Atomically consume the pending purchase callback so the underlying + * continuation cannot be resumed twice if Horizon Billing fires + * `onPurchasesUpdated` multiple times or races with an early-return path. + */ + private fun consumePurchaseCallback(result: Result>) { + currentPurchaseCallback.getAndSet(null)?.invoke(result) + } private val productManager = ProductManager() private val fallbackActivity: Activity? = if (context is Activity) context else null private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -370,9 +380,15 @@ class OpenIapModule( } suspendCancellableCoroutine> { continuation -> - currentPurchaseCallback = { result -> + val callback: (Result>) -> Unit = { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } + if (!currentPurchaseCallback.compareAndSet(null, callback)) { + OpenIapLog.w("requestPurchase rejected: another purchase is already in progress", TAG) + if (continuation.isActive) continuation.resumeWithException(OpenIapError.DeveloperError) + return@suspendCancellableCoroutine + } + continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP @@ -421,7 +437,7 @@ class OpenIapModule( OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) val err = OpenIapError.SkuOfferMismatch purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -437,7 +453,7 @@ class OpenIapModule( OpenIapLog.w("Invalid empty offerToken provided for ${productDetails.productId}", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -502,7 +518,7 @@ class OpenIapModule( else -> OpenIapError.PurchaseFailed } purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } else { // CRITICAL FIX: Proactively query purchases in case onPurchasesUpdated doesn't fire // Horizon SDK may not always trigger the callback, so we query after a delay @@ -524,7 +540,7 @@ class OpenIapModule( runCatching { listener.onPurchaseUpdated(purchase) } } } - currentPurchaseCallback?.invoke(Result.success(filtered)) + consumePurchaseCallback(Result.success(filtered)) } } catch (e: Exception) { OpenIapLog.e("Error in proactive purchase query", e, TAG) @@ -540,7 +556,7 @@ class OpenIapModule( val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } val err = OpenIapError.SkuNotFound(missingSku ?: "") purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@suspendCancellableCoroutine } buildAndLaunch(ordered) @@ -560,7 +576,7 @@ class OpenIapModule( if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) { val err = OpenIapError.QueryProduct purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync } @@ -578,7 +594,7 @@ class OpenIapModule( } val err = OpenIapError.SkuNotFound(missingSku ?: "") purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync } @@ -856,26 +872,19 @@ class OpenIapModule( } OpenIapLog.d("Invoking currentPurchaseCallback with ${mapped.size} purchases (single-shot)", TAG) - currentPurchaseCallback?.let { cb -> - currentPurchaseCallback = null - cb.invoke(Result.success(mapped)) - } - OpenIapLog.i("Purchase callback invoked", TAG) + consumePurchaseCallback(Result.success(mapped)) + OpenIapLog.i("Purchase callback invoked", TAG) } else { // Purchases is null - likely DEFERRED mode OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG) - currentPurchaseCallback?.let { cb -> - currentPurchaseCallback = null - cb.invoke(Result.success(emptyList())) - } + consumePurchaseCallback(Result.success(emptyList())) } } else { OpenIapLog.w("Purchase failed or cancelled: code=${result.responseCode}", TAG) val error = OpenIapError.fromBillingResponseCode(result.responseCode, result.debugMessage) purchaseErrorListeners.forEach { listener -> runCatching { listener.onPurchaseError(error) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } - currentPurchaseCallback = null OpenIapLog.i("=== END onPurchasesUpdated ===", TAG) } catch (e: Exception) { OpenIapLog.e("Exception in onPurchasesUpdated", e, TAG) diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt index 51fed5da..fcf67f80 100644 --- a/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt +++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt @@ -71,6 +71,7 @@ import kotlinx.coroutines.withContext import kotlin.coroutines.resume import kotlin.coroutines.resumeWithException import java.lang.ref.WeakReference +import java.util.concurrent.atomic.AtomicReference // AlternativeBillingMode moved to main source set (shared between Play and Horizon) @@ -109,7 +110,16 @@ class OpenIapModule( private val purchaseErrorListeners = mutableSetOf() private val userChoiceBillingListeners = mutableSetOf() private val developerProvidedBillingListeners = mutableSetOf() - private var currentPurchaseCallback: ((Result>) -> Unit)? = null + private val currentPurchaseCallback = AtomicReference<((Result>) -> Unit)?>(null) + + /** + * Atomically consume the pending purchase callback. Ensures the underlying + * continuation is resumed at most once even if Google Play Billing fires + * `onPurchasesUpdated` multiple times or races with an early-return path. + */ + private fun consumePurchaseCallback(result: Result>) { + currentPurchaseCallback.getAndSet(null)?.invoke(result) + } // Billing programs enabled via enableBillingProgram (8.2.0+, EXTERNAL_PAYMENTS in 8.3.0+) private val enabledBillingPrograms = mutableSetOf() @@ -850,9 +860,15 @@ class OpenIapModule( } suspendCancellableCoroutine> { continuation -> - currentPurchaseCallback = { result -> + val callback: (Result>) -> Unit = { result -> if (continuation.isActive) continuation.resume(result.getOrDefault(emptyList())) } + if (!currentPurchaseCallback.compareAndSet(null, callback)) { + OpenIapLog.w("requestPurchase rejected: another purchase is already in progress", TAG) + if (continuation.isActive) continuation.resumeWithException(OpenIapError.DeveloperError) + return@suspendCancellableCoroutine + } + continuation.invokeOnCancellation { currentPurchaseCallback.compareAndSet(callback, null) } val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP @@ -878,7 +894,7 @@ class OpenIapModule( ) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -915,7 +931,7 @@ class OpenIapModule( OpenIapLog.w("Invalid offer token: $resolved not in $availableTokens", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -942,7 +958,7 @@ class OpenIapModule( OpenIapLog.w("No one-time purchase offers available for ${productDetails.productId}, but offerToken was provided: ${androidArgs.offerToken}", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -950,7 +966,7 @@ class OpenIapModule( OpenIapLog.w("Invalid one-time offer token: ${androidArgs.offerToken} not in $availableTokens", TAG) val err = OpenIapError.SkuOfferMismatch for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return } @@ -1034,7 +1050,7 @@ class OpenIapModule( else -> OpenIapError.PurchaseFailed } for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } @@ -1044,7 +1060,7 @@ class OpenIapModule( val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } val err = OpenIapError.SkuNotFound(missingSku ?: "") for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@suspendCancellableCoroutine } buildAndLaunch(ordered) @@ -1070,14 +1086,14 @@ class OpenIapModule( val missingSku = androidArgs.skus.firstOrNull { !detailsBySku.containsKey(it) } val err = OpenIapError.SkuNotFound(missingSku ?: "") for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) return@queryProductDetailsAsync } buildAndLaunch(ordered) } else { val err = OpenIapError.QueryProduct for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } } @@ -1334,18 +1350,18 @@ class OpenIapModule( runCatching { listener.onPurchaseUpdated(converted) } } } - currentPurchaseCallback?.invoke(Result.success(mapped)) + consumePurchaseCallback(Result.success(mapped)) } else { // Purchases is null - likely DEFERRED mode OpenIapLog.d("Purchase successful but purchases list is null (DEFERRED mode)", TAG) - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } else { when (billingResult.responseCode) { BillingClient.BillingResponseCode.USER_CANCELED -> { val err = OpenIapError.UserCancelled for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } else -> { val error = OpenIapError.fromBillingResponseCode( @@ -1354,11 +1370,10 @@ class OpenIapModule( ) OpenIapLog.w("Purchase failed: code=${billingResult.responseCode} msg=${error.message}", TAG) for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(error) } } - currentPurchaseCallback?.invoke(Result.success(emptyList())) + consumePurchaseCallback(Result.success(emptyList())) } } } - currentPurchaseCallback = null } private fun buildBillingClient() {