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,