diff --git a/packages/docs/public/llms-full.txt b/packages/docs/public/llms-full.txt
index b40beca3f..732ba56d2 100644
--- a/packages/docs/public/llms-full.txt
+++ b/packages/docs/public/llms-full.txt
@@ -3,7 +3,7 @@
> OpenIAP: Unified in-app purchase specification for iOS & Android
> Documentation: https://openiap.dev
> Quick Reference: https://openiap.dev/llms.txt
-> Generated: 2026-07-26T17:17:44.905Z
+> Generated: 2026-07-27T13:04:16.611Z
## Table of Contents
1. Installation
@@ -31,22 +31,22 @@ cd ios && pod install
### Swift (iOS/macOS)
```swift
// Swift Package Manager
-.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.3")
+.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.4")
// CocoaPods
-pod 'openiap', '~> 2.4.3'
+pod 'openiap', '~> 2.4.4'
```
### Kotlin (Android)
```kotlin
// Gradle (build.gradle.kts)
-implementation("io.github.hyochan.openiap:openiap-google:2.5.0")
+implementation("io.github.hyochan.openiap:openiap-google:2.5.1")
// For Meta Horizon OS
-implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.0")
+implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.1")
// For Fire OS (Amazon Appstore)
-implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.0")
+implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.1")
```
### Flutter
@@ -55,13 +55,13 @@ flutter pub add flutter_inapp_purchase
```
### Godot
-Download `godot-iap-2.6.0.zip` from GitHub Releases, extract it to
+Download `godot-iap-2.6.1.zip` from GitHub Releases, extract it to
`addons/godot-iap/`, then enable the plugin in Project Settings.
### Kotlin Multiplatform
```kotlin
dependencies {
- implementation("io.github.hyochan:kmp-iap:2.7.0")
+ implementation("io.github.hyochan:kmp-iap:2.7.1")
}
```
@@ -73,7 +73,7 @@ https://central.sonatype.com/artifact/io.github.hyochan/kmp-iap
dotnet add package OpenIap.Maui
```
-Current NuGet package version: 1.4.0
+Current NuGet package version: 1.4.1
Requires .NET 9 or .NET 10, the MAUI workload, iOS 15.0+, and Android API 24+.
diff --git a/packages/docs/public/llms.txt b/packages/docs/public/llms.txt
index 354004caf..2a2151b2b 100644
--- a/packages/docs/public/llms.txt
+++ b/packages/docs/public/llms.txt
@@ -3,7 +3,7 @@
> OpenIAP: Unified in-app purchase specification for iOS & Android
> Documentation: https://openiap.dev
> Full Reference: https://openiap.dev/llms-full.txt
-> Generated: 2026-07-26T15:39:23.721Z
+> Generated: 2026-07-27T13:04:16.611Z
## Installation
@@ -19,14 +19,14 @@ npm install react-native-iap
### Native
```swift
// Swift Package Manager
-.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.3")
+.package(url: "https://github.com/hyodotdev/openiap.git", from: "2.4.4")
```
```kotlin
// Gradle
-implementation("io.github.hyochan.openiap:openiap-google:2.5.0")
-implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.0")
-implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.0")
+implementation("io.github.hyochan.openiap:openiap-google:2.5.1")
+implementation("io.github.hyochan.openiap:openiap-google-horizon:2.5.1")
+implementation("io.github.hyochan.openiap:openiap-google-amazon:2.5.1")
```
```bash
@@ -36,20 +36,20 @@ flutter pub add flutter_inapp_purchase
```gdscript
# Godot
-# Install godot-iap 2.6.0 to addons/godot-iap and enable the plugin
+# Install godot-iap 2.6.1 to addons/godot-iap and enable the plugin
```
```kotlin
// Kotlin Multiplatform
-implementation("io.github.hyochan:kmp-iap:2.7.0")
+implementation("io.github.hyochan:kmp-iap:2.7.1")
```
```xml
-
+
```
-Current NuGet package version: 1.4.0
+Current NuGet package version: 1.4.1
## Framework Libraries
diff --git a/packages/docs/src/pages/docs/updates/releases.tsx b/packages/docs/src/pages/docs/updates/releases.tsx
index 07b5fc659..629f7b020 100644
--- a/packages/docs/src/pages/docs/updates/releases.tsx
+++ b/packages/docs/src/pages/docs/updates/releases.tsx
@@ -83,6 +83,16 @@ const purchaseSafetyReleases = [
['OpenIap.Maui 1.2.2', 'maui-iap-1.2.2'],
] as const;
+const googlePurchaseRecoveryReleases = [
+ ['openiap-google 2.5.2', 'google-2.5.2'],
+ ['react-native-iap 15.6.2', 'react-native-iap-15.6.2'],
+ ['expo-iap 4.7.2', 'expo-iap-4.7.2'],
+ ['flutter_inapp_purchase 9.6.2', 'flutter-iap-9.6.2'],
+ ['godot-iap 2.6.2', 'godot-iap-2.6.2'],
+ ['kmp-iap 2.7.2', 'kmp-iap-2.7.2'],
+ ['OpenIap.Maui 1.4.2', 'maui-iap-1.4.2'],
+] as const;
+
const iapkitSecurityTrainReleases = [
['OpenIAP Spec 2.4.4', 'docs-2.4.4'],
['openiap-apple 2.4.4', '2.4.4'],
@@ -108,6 +118,164 @@ function Releases() {
useScrollToHash();
const allNotes: Note[] = [
+ // July 28, 2026 - Google Play ambiguous purchase recovery patch train
+ {
+ id: 'google-play-ambiguous-purchase-recovery-2026-07-28',
+ date: new Date('2026-07-28'),
+ element: (
+
+
+ July 28, 2026 - Google Play ambiguous purchase recovery patch train
+
+
+
+ Publishes a coordinated Android patch train for the purchase
+ completion regression reported in{' '}
+
+ issue #166
+ {' '}
+ and fixed in{' '}
+
+ PR #257
+
+ . Google Play can commit a purchase while returning a transient
+ billing error to the app. The SDK now reconciles that ambiguous
+ callback against current ownership instead of immediately reporting
+ a failed purchase. The OpenIAP Spec and Apple package are unchanged
+ because this patch adds no public API or contract.
+
+
+
Google
+
+ -
+ openiap-google 2.5.2 - recovers Google Play
+ purchase flows that receive
NETWORK_ERROR,{' '}
+ SERVICE_UNAVAILABLE,{' '}
+ SERVICE_DISCONNECTED, or an ambiguous{' '}
+ ERROR after the store has already committed the
+ transaction. Recovery queries the requested product type and SKU,
+ ignores ownership older than the current flow, and completes the
+ original request exactly once.
+
+ -
+ Transient ownership-query failures retry up to three total
+ attempts with a 500 ms delay. Fatal billing results and
+ synchronous exceptions fail immediately, while stale purchases,
+ unrelated products, duplicate callbacks, and a replaced purchase
+ flow cannot satisfy recovery.
+
+
+
+
Framework libraries
+
+ -
+ react-native-iap 15.6.2 and{' '}
+ expo-iap 4.7.2 ship the corrected Google Play
+ purchase runtime for Nitro Modules and Expo Modules without a
+ JavaScript API change.
+
+ -
+ flutter_inapp_purchase 9.6.2,{' '}
+ godot-iap 2.6.2, and{' '}
+ kmp-iap 2.7.2 carry the same bounded native
+ recovery through their Android packages without changing their
+ public purchase contracts.
+
+ -
+ OpenIap.Maui 1.4.2 rebuilds its Android binding
+ with the corrected Google package; its CLR API remains unchanged.
+
+
+
+
+ Integration and validation notes
+
+
+ -
+ No application migration is required. Upgrade the package used by
+ the app and keep the normal purchase-success and purchase-error
+ listeners active until the purchase flow completes.
+
+ -
+ The regression suite covers delayed ownership visibility,
+ transient retry exhaustion, fatal query failures, old ownership,
+ base-plan purchases, duplicate completion, and replacement-flow
+ races. A physical Google Play license-tester purchase also
+ completed local IAPKit verification and consumption without
+ creating a duplicate logical order.
+
+
+
+
+
Package Releases
+
+ {googlePurchaseRecoveryReleases.map(([label, tag]) => (
+ -
+
+ {label}
+
+
+ ))}
+
+
+
+ ),
+ },
+
// July 25, 2026 - IAPKit security and SDK patch train
{
id: 'iapkit-security-cross-sdk-payload-integrity-2026-07-25',
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 5048fb366..55e27d888 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
@@ -8,6 +8,8 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
+import android.os.Handler
+import android.os.Looper
import com.android.billingclient.api.AcknowledgePurchaseParams
import com.android.billingclient.api.BillingClient
import com.android.billingclient.api.BillingClientStateListener
@@ -214,6 +216,8 @@ class OpenIapModule
companion object {
private const val TAG = "OpenIapModule"
+ private const val AMBIGUOUS_PURCHASE_QUERY_MAX_ATTEMPTS = 3
+ private const val AMBIGUOUS_PURCHASE_QUERY_RETRY_DELAY_MILLIS = 500L
}
// For backward compatibility
@@ -284,6 +288,7 @@ class OpenIapModule
val operationFailures: List<() -> Unit>,
)
private var currentActivityRef: WeakReference? = null
+ private val mainHandler by lazy { Handler(Looper.getMainLooper()) }
private val productManager = ProductManager()
private val gson = Gson()
private val fallbackActivity: Activity? = if (context is Activity) context else null
@@ -2561,6 +2566,81 @@ class OpenIapModule
}
}
+ private fun reconcilePurchaseFlowError(
+ sourceClient: BillingClient,
+ owner: ActiveStoreListenerOwner,
+ pendingRequest: PendingPurchaseSnapshot,
+ error: OpenIapError,
+ reason: String,
+ purchasedSinceMillis: Double? = null,
+ deliverIfRequestCompletedElsewhere: Boolean = false,
+ ) {
+ val desiredType = pendingRequest.requestedProductType
+ if (desiredType == null) {
+ finishPurchaseCallback(
+ sourceClient,
+ pendingRequest.callback,
+ Result.success(emptyList()),
+ error,
+ requireLaunched = true,
+ )
+ return
+ }
+
+ OpenIapLog.debug(
+ "$reason received via listener; querying owned purchases for ${pendingRequest.requestedSkus}",
+ TAG,
+ )
+ queryAlreadyOwnedPurchases(
+ sourceClient,
+ desiredType,
+ pendingRequest.requestedSkus.toList(),
+ pendingRequest.selectedBasePlanIdsBySku,
+ purchasedSinceMillis,
+ maxAttempts = if (purchasedSinceMillis != null) {
+ AMBIGUOUS_PURCHASE_QUERY_MAX_ATTEMPTS
+ } else {
+ 1
+ },
+ retryDelayMillis = AMBIGUOUS_PURCHASE_QUERY_RETRY_DELAY_MILLIS,
+ scheduleRetry = { delayMillis, retry ->
+ mainHandler.postDelayed({ retry() }, delayMillis)
+ },
+ ) { recovered ->
+ if (recovered.isEmpty()) {
+ OpenIapLog.warn("$reason recovery found no matching owned purchases", TAG)
+ finishPurchaseCallback(
+ sourceClient,
+ pendingRequest.callback,
+ Result.success(emptyList()),
+ error,
+ requireLaunched = true,
+ )
+ return@queryAlreadyOwnedPurchases
+ }
+
+ val pending = claimPurchaseCallback(
+ sourceClient,
+ pendingRequest.callback,
+ requireLaunched = true,
+ )
+ OpenIapLog.debug("Recovered ${recovered.size} owned purchase(s) after $reason", TAG)
+ val delivered = if (pending != null || deliverIfRequestCompletedElsewhere) {
+ deliverPurchasesIfActive(recovered, owner)
+ } else {
+ false
+ }
+ if (pending != null) {
+ pending.callback(Result.success(recovered))
+ } else {
+ OpenIapLog.warn(
+ "Purchase request completed elsewhere; recovered purchases delivered to active listeners=$delivered",
+ TAG,
+ )
+ }
+ }
+ }
+
private fun onPurchasesUpdated(
sourceClient: BillingClient,
owner: ActiveStoreListenerOwner,
@@ -2710,59 +2790,40 @@ class OpenIapModule
// result. Mirror the synchronous recovery: query the owned
// purchases for the in-flight request and treat a match as
// success instead of failing the purchase.
- val desiredType = pendingRequest?.requestedProductType
- if (pendingRequest != null && desiredType != null) {
- OpenIapLog.debug(
- "ITEM_ALREADY_OWNED received via listener; querying owned purchases for ${pendingRequest.requestedSkus}",
- TAG,
+ if (pendingRequest != null) {
+ reconcilePurchaseFlowError(
+ sourceClient = sourceClient,
+ owner = owner,
+ pendingRequest = pendingRequest,
+ error = error,
+ reason = "ITEM_ALREADY_OWNED",
+ deliverIfRequestCompletedElsewhere = true,
+ )
+ } else {
+ OpenIapLog.warn("Purchase failed: code=${billingResult.responseCode} msg=${error.message}", TAG)
+ }
+ }
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
+ BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
+ BillingClient.BillingResponseCode.ERROR -> {
+ val error = OpenIapError.fromBillingResponseCode(
+ billingResult.responseCode,
+ billingResult.debugMessage,
+ subResponseCode,
+ )
+ val launchStartedAtMillis = pendingRequest?.launchStartedAtMillis
+ if (pendingRequest != null && launchStartedAtMillis != null) {
+ reconcilePurchaseFlowError(
+ sourceClient = sourceClient,
+ owner = owner,
+ pendingRequest = pendingRequest,
+ error = error,
+ reason = "ambiguous purchase-flow error ${billingResult.responseCode}",
+ purchasedSinceMillis = launchStartedAtMillis,
)
- queryAlreadyOwnedPurchases(
- sourceClient,
- desiredType,
- pendingRequest.requestedSkus.toList(),
- pendingRequest.selectedBasePlanIdsBySku,
- ) { recovered ->
- if (recovered.isNotEmpty()) {
- val pending = claimPurchaseCallback(
- sourceClient,
- pendingRequest.callback,
- requireLaunched = true,
- )
- OpenIapLog.debug("Recovered ${recovered.size} already-owned purchase(s)", TAG)
- val delivered = deliverPurchasesIfActive(
- recovered,
- owner,
- )
- if (pending != null) {
- pending.callback(Result.success(recovered))
- } else {
- OpenIapLog.warn(
- "Purchase request completed elsewhere; recovered purchases delivered to active listeners=$delivered",
- TAG,
- )
- }
- } else {
- OpenIapLog.warn("ITEM_ALREADY_OWNED recovery found no matching owned purchases", TAG)
- finishPurchaseCallback(
- sourceClient,
- pendingRequest.callback,
- Result.success(emptyList()),
- error,
- requireLaunched = true,
- )
- }
- }
} else {
OpenIapLog.warn("Purchase failed: code=${billingResult.responseCode} msg=${error.message}", TAG)
- if (pendingRequest != null) {
- finishPurchaseCallback(
- sourceClient,
- pendingRequest.callback,
- Result.success(emptyList()),
- error,
- requireLaunched = true,
- )
- }
}
}
else -> {
diff --git a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
index 370a36b06..6a7f306a7 100644
--- a/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
+++ b/packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt
@@ -12,6 +12,14 @@ import java.util.concurrent.atomic.AtomicBoolean
// Common helpers (onPurchaseUpdated, onPurchaseError, AndroidPurchaseArgs,
// toAndroidPurchaseArgs, toPurchaseError) are in main/helpers/CommonHelpers.kt
+private fun isRetriablePurchaseQueryResponse(responseCode: Int): Boolean = when (responseCode) {
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
+ BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
+ BillingClient.BillingResponseCode.ERROR -> true
+ else -> false
+}
+
internal suspend fun restorePurchases(
client: BillingClient?,
operations: ActiveStoreOperationRegistry,
@@ -65,51 +73,83 @@ internal suspend fun queryPurchases(
}
/**
- * Queries Play Billing directly after ITEM_ALREADY_OWNED and returns only
- * currently owned purchases that match the in-flight request SKUs.
+ * Queries Play Billing for currently owned purchases that match an in-flight
+ * request. When [purchasedSinceMillis] is set, older ownership is excluded so
+ * a transient purchase-flow error cannot turn a pre-existing purchase into a
+ * false success. [maxAttempts] applies only when the ownership query itself
+ * fails; eligible retries run through [scheduleRetry] after [retryDelayMillis].
+ * A successful empty response is authoritative.
*/
internal fun queryAlreadyOwnedPurchases(
client: BillingClient?,
productType: String,
skus: List,
basePlanIdsBySku: Map = emptyMap(),
+ purchasedSinceMillis: Double? = null,
+ maxAttempts: Int = 1,
+ retryDelayMillis: Long = 0,
+ scheduleRetry: (Long, () -> Unit) -> Boolean = { _, retry ->
+ retry()
+ true
+ },
onResult: (List) -> Unit
) {
val requestedSkus = skus.toSet()
- if (client == null || requestedSkus.isEmpty()) {
+ if (client == null || requestedSkus.isEmpty() || maxAttempts < 1) {
onResult(emptyList())
return
}
-
- val didHandleResult = AtomicBoolean(false)
val params = QueryPurchasesParams.newBuilder()
.setProductType(productType)
.build()
- try {
- client.queryPurchasesAsync(params) { result, purchaseList ->
- if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync
-
- if (result.responseCode != BillingClient.BillingResponseCode.OK) {
- onResult(emptyList())
- return@queryPurchasesAsync
- }
+ fun query(attempt: Int) {
+ val didHandleResult = AtomicBoolean(false)
+ try {
+ client.queryPurchasesAsync(params) { result, purchaseList ->
+ if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync
- val recovered = purchaseList.orEmpty().mapNotNull { billingPurchase ->
- val matchingSku = billingPurchase.products.firstOrNull { productId ->
- productId in requestedSkus
+ if (result.responseCode != BillingClient.BillingResponseCode.OK) {
+ if (attempt < maxAttempts &&
+ isRetriablePurchaseQueryResponse(result.responseCode)
+ ) {
+ val scheduled = runCatching {
+ scheduleRetry(retryDelayMillis) {
+ query(attempt + 1)
+ }
+ }.getOrDefault(false)
+ if (!scheduled) onResult(emptyList())
+ } else {
+ onResult(emptyList())
+ }
+ return@queryPurchasesAsync
}
- matchingSku?.let { sku ->
- billingPurchase.toPurchase(productType, basePlanIdsBySku[sku])
+
+ val recovered = purchaseList.orEmpty().mapNotNull { billingPurchase ->
+ if (purchasedSinceMillis != null &&
+ billingPurchase.purchaseTime.toDouble() < purchasedSinceMillis
+ ) {
+ return@mapNotNull null
+ }
+ val matchingSku = billingPurchase.products.firstOrNull { productId ->
+ productId in requestedSkus
+ }
+ matchingSku?.let { sku ->
+ billingPurchase.toPurchase(productType, basePlanIdsBySku[sku])
+ }
}
+ onResult(recovered)
+ }
+ } catch (_: Exception) {
+ if (didHandleResult.compareAndSet(false, true)) {
+ // Synchronous API exceptions do not carry a BillingResult that
+ // can establish a transient failure, so do not retry them.
+ onResult(emptyList())
}
- onResult(recovered)
- }
- } catch (_: Exception) {
- if (didHandleResult.compareAndSet(false, true)) {
- onResult(emptyList())
}
}
+
+ query(attempt = 1)
}
internal data class SubscriptionBasePlanOffer(
diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
index 620b249cb..0c8a20825 100644
--- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
+++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/OnPurchasesUpdatedRecoveryTest.kt
@@ -50,11 +50,14 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowLooper
/**
* Covers the asynchronous PurchasesUpdatedListener path of the Play-flavor
* OpenIapModule (GitHub issue #166):
*
+ * - Ambiguous, retriable purchase-flow errors must query current ownership and
+ * recover only matching purchases created during the in-flight request.
* - ITEM_ALREADY_OWNED delivered via the listener (instead of the synchronous
* launchBillingFlow result) must recover the owned purchases, notify
* purchase-update listeners, and resolve the pending request.
@@ -73,6 +76,326 @@ class OnPurchasesUpdatedRecoveryTest {
OpenIapLog.enable(false)
}
+ @Test
+ fun `listener NETWORK_ERROR recovers a newly purchased subscription`() {
+ val client = RecordingBillingClient(
+ ownedPurchases = listOf(
+ billingPurchase("subscription-id", "subscription-token", purchaseTime = 1_001)
+ )
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("subscription-id"),
+ productType = BillingClient.ProductType.SUBS,
+ launchStartedAtMillis = 1_000.0,
+ selectedBasePlanIdsBySku = mapOf("subscription-id" to "premium-monthly"),
+ )
+ val updates = mutableListOf()
+ val errors = mutableListOf()
+ module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it })
+ module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it })
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.NETWORK_ERROR, "network lost"),
+ null,
+ )
+
+ assertEquals(1, client.queryPurchasesCalls.get())
+ assertEquals(listOf("subscription-id"), updates.map { it.productId })
+ assertEquals(listOf("premium-monthly"), updates.map { it.currentPlanId })
+ assertEquals(1, results.size)
+ assertEquals(
+ listOf("subscription-token"),
+ results.single().getOrThrow().map { it.purchaseToken },
+ )
+ assertTrue("successful recovery must not emit purchase errors: $errors", errors.isEmpty())
+ assertNull(pendingPurchaseField().get(module))
+ }
+
+ @Test
+ fun `listener NETWORK_ERROR retries transient ownership query failures`() {
+ val purchaseResponseCodes = listOf(
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
+ BillingClient.BillingResponseCode.OK,
+ )
+ val client = RecordingBillingClient(
+ ownedPurchases = listOf(
+ billingPurchase("product-id", "purchase-token", purchaseTime = 1_001)
+ ),
+ purchaseResponseCodes = purchaseResponseCodes,
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("product-id"),
+ productType = BillingClient.ProductType.INAPP,
+ launchStartedAtMillis = 1_000.0,
+ )
+ val updates = mutableListOf()
+ val errors = mutableListOf()
+ module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it })
+ module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it })
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.NETWORK_ERROR, "network lost"),
+ null,
+ )
+ assertEquals(1, client.queryPurchasesCalls.get())
+ assertTrue("retry result must remain pending until the delay elapses", results.isEmpty())
+ repeat(purchaseResponseCodes.lastIndex) {
+ ShadowLooper.runMainLooperToNextTask()
+ }
+
+ assertEquals(purchaseResponseCodes.size, client.queryPurchasesCalls.get())
+ assertEquals(listOf("purchase-token"), updates.map { it.purchaseToken })
+ assertEquals(
+ listOf("purchase-token"),
+ results.single().getOrThrow().map { it.purchaseToken },
+ )
+ assertTrue("successful retry must not emit purchase errors: $errors", errors.isEmpty())
+ assertNull(pendingPurchaseField().get(module))
+ }
+
+ @Test
+ fun `listener NETWORK_ERROR preserves original error after retries exhaust`() {
+ val purchaseResponseCodes = listOf(
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ )
+ val client = RecordingBillingClient(
+ ownedPurchases = listOf(
+ billingPurchase("product-id", "purchase-token", purchaseTime = 1_001)
+ ),
+ purchaseResponseCodes = purchaseResponseCodes,
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("product-id"),
+ productType = BillingClient.ProductType.INAPP,
+ launchStartedAtMillis = 1_000.0,
+ )
+ val updates = mutableListOf()
+ val errors = mutableListOf()
+ module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it })
+ module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it })
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.NETWORK_ERROR, "network lost"),
+ null,
+ )
+ assertEquals(1, client.queryPurchasesCalls.get())
+ assertTrue("retry result must remain pending until the delay elapses", results.isEmpty())
+ repeat(purchaseResponseCodes.lastIndex) {
+ ShadowLooper.runMainLooperToNextTask()
+ }
+
+ assertEquals(purchaseResponseCodes.size, client.queryPurchasesCalls.get())
+ assertTrue("failed retries must not deliver purchases: $updates", updates.isEmpty())
+ assertEquals(emptyList(), results.single().getOrThrow())
+ assertEquals(1, errors.size)
+ assertTrue(errors.single() is OpenIapError.NetworkFailure)
+ assertNull(pendingPurchaseField().get(module))
+ }
+
+ @Test
+ fun `listener NETWORK_ERROR does not retry a fatal ownership query failure`() {
+ val client = RecordingBillingClient(
+ purchaseResponseCodes = listOf(
+ BillingClient.BillingResponseCode.DEVELOPER_ERROR,
+ ),
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("product-id"),
+ productType = BillingClient.ProductType.INAPP,
+ launchStartedAtMillis = 1_000.0,
+ )
+ val errors = mutableListOf()
+ module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it })
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.NETWORK_ERROR, "network lost"),
+ null,
+ )
+
+ assertEquals(1, client.queryPurchasesCalls.get())
+ assertEquals(emptyList(), results.single().getOrThrow())
+ assertEquals(1, errors.size)
+ assertTrue(errors.single() is OpenIapError.NetworkFailure)
+ assertNull(pendingPurchaseField().get(module))
+ }
+
+ @Test
+ fun `ambiguous purchase errors reconcile current ownership`() {
+ val ambiguousCodes = listOf(
+ BillingClient.BillingResponseCode.NETWORK_ERROR,
+ BillingClient.BillingResponseCode.SERVICE_UNAVAILABLE,
+ BillingClient.BillingResponseCode.SERVICE_DISCONNECTED,
+ BillingClient.BillingResponseCode.ERROR,
+ )
+
+ for (responseCode in ambiguousCodes) {
+ val client = RecordingBillingClient(
+ ownedPurchases = listOf(
+ billingPurchase("product-id", "token-$responseCode", purchaseTime = 1_001)
+ )
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("product-id"),
+ productType = BillingClient.ProductType.INAPP,
+ launchStartedAtMillis = 1_000.0,
+ )
+ val updates = mutableListOf()
+ module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it })
+
+ module.onPurchasesUpdated(billingResult(responseCode, "ambiguous"), null)
+
+ assertEquals("responseCode=$responseCode", 1, client.queryPurchasesCalls.get())
+ assertEquals("responseCode=$responseCode", 1, results.size)
+ assertEquals(
+ "responseCode=$responseCode",
+ listOf("token-$responseCode"),
+ results.single().getOrThrow().map { it.purchaseToken },
+ )
+ assertEquals(
+ "responseCode=$responseCode",
+ listOf("token-$responseCode"),
+ updates.map { it.purchaseToken },
+ )
+ }
+ }
+
+ @Test
+ fun `listener NETWORK_ERROR does not recover pre-existing ownership`() {
+ val client = RecordingBillingClient(
+ ownedPurchases = listOf(
+ billingPurchase("subscription-id", "old-token", purchaseTime = 999)
+ )
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("subscription-id"),
+ productType = BillingClient.ProductType.SUBS,
+ launchStartedAtMillis = 1_000.0,
+ )
+ val updates = mutableListOf()
+ val errors = mutableListOf()
+ module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it })
+ module.addPurchaseErrorListener(OpenIapPurchaseErrorListener { errors += it })
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.NETWORK_ERROR, "network lost"),
+ null,
+ )
+
+ assertEquals(1, client.queryPurchasesCalls.get())
+ assertTrue("old ownership must not be delivered: $updates", updates.isEmpty())
+ assertEquals(1, results.size)
+ assertEquals(emptyList(), results.single().getOrThrow())
+ assertEquals(1, errors.size)
+ assertTrue(errors.single() is OpenIapError.NetworkFailure)
+ assertNull(pendingPurchaseField().get(module))
+ }
+
+ @Test
+ fun `non ambiguous purchase error does not query ownership`() {
+ val client = RecordingBillingClient(
+ ownedPurchases = listOf(
+ billingPurchase("product-id", "owned-token", purchaseTime = 1_001)
+ )
+ )
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("product-id"),
+ productType = BillingClient.ProductType.INAPP,
+ launchStartedAtMillis = 1_000.0,
+ )
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.BILLING_UNAVAILABLE, "unavailable"),
+ null,
+ )
+
+ assertEquals(0, client.queryPurchasesCalls.get())
+ assertEquals(1, results.size)
+ assertEquals(emptyList(), results.single().getOrThrow())
+ }
+
+ @Test
+ fun `ambiguous recovery does not duplicate a purchase completed during query`() {
+ val recovered = billingPurchase("product-id", "purchase-token", purchaseTime = 1_001)
+ val client = RecordingBillingClient(ownedPurchases = listOf(recovered))
+ val module = module()
+ setBillingClient(module, client)
+ val results = mutableListOf>>()
+ installPendingPurchase(
+ module = module,
+ client = client,
+ callback = { results += it },
+ skus = setOf("product-id"),
+ productType = BillingClient.ProductType.INAPP,
+ launchStartedAtMillis = 1_000.0,
+ )
+ val updates = mutableListOf()
+ module.addPurchaseUpdateListener(OpenIapPurchaseUpdateListener { updates += it })
+ client.beforeQueryPurchasesResponse = {
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.OK),
+ listOf(recovered),
+ )
+ }
+
+ module.onPurchasesUpdated(
+ billingResult(BillingClient.BillingResponseCode.NETWORK_ERROR, "network lost"),
+ null,
+ )
+
+ assertEquals(1, client.queryPurchasesCalls.get())
+ assertEquals(1, results.size)
+ assertEquals(
+ "the same purchase must only reach listeners once",
+ listOf("purchase-token"),
+ updates.map { it.purchaseToken },
+ )
+ }
+
@Test
fun `listener ITEM_ALREADY_OWNED with pending request recovers owned purchases`() {
val client = RecordingBillingClient(
@@ -363,6 +686,7 @@ class OnPurchasesUpdatedRecoveryTest {
skus: Set,
productType: String,
launchStartedAtMillis: Double?,
+ selectedBasePlanIdsBySku: Map = emptyMap(),
) {
val snapshotClass = Class.forName("dev.hyo.openiap.OpenIapModule\$PendingPurchaseSnapshot")
val constructor = snapshotClass.declaredConstructors.first { candidate ->
@@ -375,7 +699,7 @@ class OnPurchasesUpdatedRecoveryTest {
callback,
skus,
productType,
- emptyMap(),
+ selectedBasePlanIdsBySku,
launchStartedAtMillis,
)
pendingPurchaseField().set(module, snapshot)
@@ -410,9 +734,12 @@ class OnPurchasesUpdatedRecoveryTest {
private class RecordingBillingClient(
private val ownedPurchases: List = emptyList(),
+ purchaseResponseCodes: List = emptyList(),
) : BillingClient() {
val queryPurchasesCalls = AtomicInteger(0)
var beforeQueryPurchasesResponse: (() -> Unit)? = null
+ private val purchaseResponseCodes =
+ java.util.concurrent.ConcurrentLinkedQueue(purchaseResponseCodes)
override fun queryPurchasesAsync(
params: QueryPurchasesParams,
@@ -421,7 +748,9 @@ class OnPurchasesUpdatedRecoveryTest {
queryPurchasesCalls.incrementAndGet()
beforeQueryPurchasesResponse?.invoke()
val result = BillingResult.newBuilder()
- .setResponseCode(BillingResponseCode.OK)
+ .setResponseCode(
+ purchaseResponseCodes.poll() ?: BillingResponseCode.OK
+ )
.build()
listener.onQueryPurchasesResponse(result, ownedPurchases)
}
diff --git a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt
index 7b08f3ce7..c30ed7f39 100644
--- a/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt
+++ b/packages/google/openiap/src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt
@@ -118,6 +118,28 @@ class QueryPurchasesRaceTest {
assertEquals(listOf("requested-product"), recoveredProductIds)
}
+ @Test
+ fun `queryAlreadyOwnedPurchases excludes ownership older than the purchase flow`() {
+ val client = DuplicateBillingClient(
+ purchases = listOf(
+ billingPurchase("requested-product", "old-token", purchaseTime = 999),
+ billingPurchase("requested-product", "new-token", purchaseTime = 1_001),
+ )
+ )
+ val recoveredTokens = mutableListOf()
+
+ queryAlreadyOwnedPurchases(
+ client,
+ BillingClient.ProductType.SUBS,
+ listOf("requested-product"),
+ purchasedSinceMillis = 1_000.0,
+ ) { purchases ->
+ recoveredTokens += purchases.mapNotNull { it.purchaseToken }
+ }
+
+ assertEquals(listOf("new-token"), recoveredTokens)
+ }
+
@Test
fun `queryAlreadyOwnedPurchases preserves requested subscription base plan`() {
val client = DuplicateBillingClient(
@@ -270,14 +292,18 @@ class QueryPurchasesRaceTest {
}
}
- private fun billingPurchase(productId: String, token: String): Purchase = Purchase(
+ private fun billingPurchase(
+ productId: String,
+ token: String,
+ purchaseTime: Long = 1,
+ ): Purchase = Purchase(
"""
{
"orderId": "order-$productId",
"packageName": "dev.hyo.openiap.test",
"productId": "$productId",
"productIds": ["$productId"],
- "purchaseTime": 1,
+ "purchaseTime": $purchaseTime,
"purchaseState": 0,
"purchaseToken": "$token",
"quantity": 1,