Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .claude/commands/resolve-issue.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<PR_NUMBER>/labels \
-f "labels[]=<label1>" \
-f "labels[]=<label2>"
```

### 5. Comment on the Issue

Always comment on the issue with your findings:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -97,7 +98,16 @@ class OpenIapModule(

private var billingClient: BillingClient? = null
private var currentActivityRef: WeakReference<Activity>? = null
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null
private val currentPurchaseCallback = AtomicReference<((Result<List<Purchase>>) -> 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<List<Purchase>>) {
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)
Expand Down Expand Up @@ -370,9 +380,15 @@ class OpenIapModule(
}

suspendCancellableCoroutine<List<Purchase>> { continuation ->
currentPurchaseCallback = { result ->
val callback: (Result<List<Purchase>>) -> 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

Expand Down Expand Up @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -109,7 +110,16 @@ class OpenIapModule(
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
private val userChoiceBillingListeners = mutableSetOf<OpenIapUserChoiceBillingListener>()
private val developerProvidedBillingListeners = mutableSetOf<OpenIapDeveloperProvidedBillingListener>()
private var currentPurchaseCallback: ((Result<List<Purchase>>) -> Unit)? = null
private val currentPurchaseCallback = AtomicReference<((Result<List<Purchase>>) -> 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<List<Purchase>>) {
currentPurchaseCallback.getAndSet(null)?.invoke(result)
}

// Billing programs enabled via enableBillingProgram (8.2.0+, EXTERNAL_PAYMENTS in 8.3.0+)
private val enabledBillingPrograms = mutableSetOf<BillingProgramAndroid>()
Expand Down Expand Up @@ -850,9 +860,15 @@ class OpenIapModule(
}

suspendCancellableCoroutine<List<Purchase>> { continuation ->
currentPurchaseCallback = { result ->
val callback: (Result<List<Purchase>>) -> 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

Expand All @@ -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
}

Expand Down Expand Up @@ -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
}

Expand All @@ -942,15 +958,15 @@ 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
}

if (!availableTokens.contains(androidArgs.offerToken)) {
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
}

Expand Down Expand Up @@ -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()))
}
}

Expand All @@ -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)
Expand All @@ -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()))
}
}
}
Expand Down Expand Up @@ -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(
Expand All @@ -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() {
Expand Down
Loading