From a0830936cd5e94ddbd9b6b3efb2dfc4c22c66aae Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 00:35:02 +0900 Subject: [PATCH 1/7] fix(google): recover already-owned purchases When Play Billing reports ITEM_ALREADY_OWNED during a purchase flow, query the current owned purchases for the requested SKU and publish matching purchases to update listeners instead of only surfacing an already-owned error. Closes #166 --- .../java/dev/hyo/openiap/OpenIapModule.kt | 33 +++++++- .../java/dev/hyo/openiap/helpers/Helpers.kt | 40 ++++++++++ .../dev/hyo/openiap/QueryPurchasesRaceTest.kt | 75 ++++++++++++++++++- 3 files changed, 142 insertions(+), 6 deletions(-) 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 316a43393..31b2c7965 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 @@ -51,6 +51,7 @@ import dev.hyo.openiap.helpers.AndroidPurchaseArgs import dev.hyo.openiap.helpers.onPurchaseError import dev.hyo.openiap.helpers.onPurchaseUpdated import dev.hyo.openiap.helpers.onSubscriptionBillingIssue +import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases import dev.hyo.openiap.helpers.queryProductDetails import dev.hyo.openiap.helpers.queryPurchases import dev.hyo.openiap.helpers.resumeGuard @@ -107,8 +108,8 @@ class OpenIapModule( private val gson = Gson() private val fallbackActivity: Activity? = if (context is Activity) context else null - private val purchaseUpdateListeners = mutableSetOf() - private val purchaseErrorListeners = mutableSetOf() + private val purchaseUpdateListeners = java.util.concurrent.CopyOnWriteArraySet() + private val purchaseErrorListeners = java.util.concurrent.CopyOnWriteArraySet() private val userChoiceBillingListeners = mutableSetOf() private val developerProvidedBillingListeners = mutableSetOf() // Thread-safe: listeners can be added/removed on the main thread while @@ -901,6 +902,8 @@ class OpenIapModule( return@withContext emptyList() } + val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP + suspendCancellableCoroutine> { continuation -> var callbackRef: ((Result>) -> Unit)? = null val resumer = continuation.resumeGuard { @@ -922,8 +925,6 @@ class OpenIapModule( return@suspendCancellableCoroutine } - val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP - val detailsBySku = mutableMapOf() for (sku in androidArgs.skus) { productManager.get(sku)?.takeIf { it.productType == desiredType }?.let { detailsBySku[sku] = it } @@ -1093,6 +1094,30 @@ class OpenIapModule( val result = client.launchBillingFlow(activity, flowBuilder.build()) OpenIapLog.d("launchBillingFlow result: ${result.responseCode} - ${result.debugMessage}", TAG) if (result.responseCode != BillingClient.BillingResponseCode.OK) { + if (result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) { + val err = OpenIapError.fromBillingResponseCode( + result.responseCode, + result.debugMessage + ) + OpenIapLog.d("ITEM_ALREADY_OWNED received; querying owned purchases for ${androidArgs.skus}", TAG) + queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus) { recovered -> + if (recovered.isNotEmpty()) { + OpenIapLog.d("Recovered ${recovered.size} already-owned purchase(s)", TAG) + consumePurchaseCallback(Result.success(recovered)) + notifySuspendedSubscriptions(recovered) + for (purchase in recovered) { + for (listener in purchaseUpdateListeners) { + runCatching { listener.onPurchaseUpdated(purchase) } + } + } + } else { + OpenIapLog.w("ITEM_ALREADY_OWNED recovery found no matching owned purchases", TAG) + for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } + consumePurchaseCallback(Result.success(emptyList())) + } + } + return + } val err = when (result.responseCode) { BillingClient.BillingResponseCode.DEVELOPER_ERROR -> { OpenIapLog.w("DEVELOPER_ERROR: Invalid arguments. Check if subscriptions are in the same group.", TAG) 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 2adc36efe..66f68e5c8 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 @@ -7,6 +7,7 @@ import dev.hyo.openiap.OpenIapError import dev.hyo.openiap.Purchase import dev.hyo.openiap.utils.BillingConverters.toPurchase import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean // Common helpers (onPurchaseUpdated, onPurchaseError, AndroidPurchaseArgs, // toAndroidPurchaseArgs, toPurchaseError) are in main/helpers/CommonHelpers.kt @@ -69,6 +70,45 @@ 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. + */ +internal fun queryAlreadyOwnedPurchases( + client: BillingClient?, + productType: String, + skus: List, + onResult: (List) -> Unit +) { + val requestedSkus = skus.toSet() + if (client == null || requestedSkus.isEmpty()) { + onResult(emptyList()) + return + } + + val didHandleResult = AtomicBoolean(false) + val params = QueryPurchasesParams.newBuilder() + .setProductType(productType) + .build() + + client.queryPurchasesAsync(params) { result, purchaseList -> + if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync + + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + onResult(emptyList()) + return@queryPurchasesAsync + } + + val recovered = purchaseList.orEmpty() + .map { billingPurchase -> billingPurchase.toPurchase(productType, null) } + .filter { purchase -> + purchase.productId in requestedSkus || + purchase.ids.orEmpty().any { id -> id in requestedSkus } + } + onResult(recovered) + } +} + internal suspend fun queryProductDetails( client: BillingClient?, productManager: ProductManager, 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 118a00085..b702973c8 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 @@ -33,15 +33,21 @@ import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsResult import com.android.billingclient.api.QueryPurchasesParams import dev.hyo.openiap.helpers.ProductManager +import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases import dev.hyo.openiap.helpers.queryPurchases import java.util.Collections import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger import kotlin.concurrent.thread import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +@RunWith(RobolectricTestRunner::class) class QueryPurchasesRaceTest { @Test @@ -57,6 +63,51 @@ class QueryPurchasesRaceTest { ) } + @Test + fun `queryAlreadyOwnedPurchases tolerates duplicate concurrent callbacks`() { + val client = DuplicateBillingClient() + val completions = AtomicInteger(0) + + queryAlreadyOwnedPurchases( + client, + BillingClient.ProductType.INAPP, + listOf("product-id") + ) { + completions.incrementAndGet() + } + + assertEquals(1, completions.get()) + assertTrue( + "queryAlreadyOwnedPurchases must ignore duplicate concurrent callbacks: " + + client.callbackFailures.joinToString { it::class.java.simpleName }, + client.callbackFailures.isEmpty() + ) + } + + @Test + fun `queryAlreadyOwnedPurchases filters purchases by requested sku`() { + val requested = billingPurchase("requested-product", "requested-token") + assertEquals(listOf("requested-product"), requested.products) + + val client = DuplicateBillingClient( + purchases = listOf( + requested, + billingPurchase("other-product", "other-token") + ) + ) + val recoveredProductIds = mutableListOf() + + queryAlreadyOwnedPurchases( + client, + BillingClient.ProductType.SUBS, + listOf("requested-product") + ) { purchases -> + recoveredProductIds += purchases.map { it.productId } + } + + assertEquals(listOf("requested-product"), recoveredProductIds) + } + @Test fun `ProductManager getOrQuery tolerates duplicate concurrent callbacks`() = runTest { val client = DuplicateBillingClient() @@ -71,7 +122,25 @@ class QueryPurchasesRaceTest { ) } - private class DuplicateBillingClient : BillingClient() { + private fun billingPurchase(productId: String, token: String): Purchase = Purchase( + """ + { + "orderId": "order-$productId", + "packageName": "dev.hyo.openiap.test", + "productId": "$productId", + "purchaseTime": 1, + "purchaseState": 0, + "purchaseToken": "$token", + "quantity": 1, + "acknowledged": false + } + """.trimIndent(), + "signature" + ) + + private class DuplicateBillingClient( + private val purchases: List = emptyList() + ) : BillingClient() { val callbackFailures = Collections.synchronizedList(mutableListOf()) override fun queryPurchasesAsync( @@ -81,7 +150,9 @@ class QueryPurchasesRaceTest { val result = BillingResult.newBuilder() .setResponseCode(BillingResponseCode.OK) .build() - val purchaseList = CallbackBarrierList(callbackCount = 2) + val purchaseList = purchases.ifEmpty { + CallbackBarrierList(callbackCount = 2) + } runDuplicateCallbacks { listener.onQueryPurchasesResponse(result, purchaseList) } From 0568f2c0628d6f018e1cd9c66157f681a9d95e83 Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 00:54:59 +0900 Subject: [PATCH 2/7] fix(google): align recovered purchase notification --- .../openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 31b2c7965..51f11b059 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 @@ -1103,13 +1103,13 @@ class OpenIapModule( queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus) { recovered -> if (recovered.isNotEmpty()) { OpenIapLog.d("Recovered ${recovered.size} already-owned purchase(s)", TAG) - consumePurchaseCallback(Result.success(recovered)) notifySuspendedSubscriptions(recovered) for (purchase in recovered) { for (listener in purchaseUpdateListeners) { runCatching { listener.onPurchaseUpdated(purchase) } } } + consumePurchaseCallback(Result.success(recovered)) } else { OpenIapLog.w("ITEM_ALREADY_OWNED recovery found no matching owned purchases", TAG) for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } } From 3b28c81bf635718fcf1ac4acaf662a58b71e25aa Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 00:55:30 +0900 Subject: [PATCH 3/7] test(google): include product ids in purchase fixture --- .../src/testPlay/java/dev/hyo/openiap/QueryPurchasesRaceTest.kt | 1 + 1 file changed, 1 insertion(+) 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 b702973c8..c53c074bd 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 @@ -128,6 +128,7 @@ class QueryPurchasesRaceTest { "orderId": "order-$productId", "packageName": "dev.hyo.openiap.test", "productId": "$productId", + "productIds": ["$productId"], "purchaseTime": 1, "purchaseState": 0, "purchaseToken": "$token", From a82c96890b45ac8267c1c39a6b2c0f1080ae6945 Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 00:59:01 +0900 Subject: [PATCH 4/7] fix(google): use thread-safe billing listeners --- .../openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 51f11b059..4dc0e2ef9 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 @@ -110,8 +110,8 @@ class OpenIapModule( private val purchaseUpdateListeners = java.util.concurrent.CopyOnWriteArraySet() private val purchaseErrorListeners = java.util.concurrent.CopyOnWriteArraySet() - private val userChoiceBillingListeners = mutableSetOf() - private val developerProvidedBillingListeners = mutableSetOf() + private val userChoiceBillingListeners = java.util.concurrent.CopyOnWriteArraySet() + private val developerProvidedBillingListeners = java.util.concurrent.CopyOnWriteArraySet() // Thread-safe: listeners can be added/removed on the main thread while // notifySuspendedSubscriptions iterates from Dispatchers.IO. private val subscriptionBillingIssueListeners = From 47880bd98b5df846a27f5b229b84b82fd60a1e02 Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 02:59:11 +0900 Subject: [PATCH 5/7] fix(ci): sync generated version metadata --- .github/workflows/ci.yml | 3 +++ .github/workflows/release-apple.yml | 4 ++++ .github/workflows/release-google.yml | 4 ++++ .github/workflows/release-maui.yml | 5 +++++ .github/workflows/release.yml | 4 ++++ scripts/audit-non-godot-parity.mjs | 2 ++ scripts/deploy.sh | 3 ++- 7 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e633774a5..728186cbf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -80,6 +80,9 @@ jobs: with: node-version: 20 + - name: Sync generated version files + run: ./scripts/sync-versions.sh + - name: Run non-Godot SDK parity audit run: node scripts/audit-non-godot-parity.mjs diff --git a/.github/workflows/release-apple.yml b/.github/workflows/release-apple.yml index 0b88ecbfa..483db04a8 100644 --- a/.github/workflows/release-apple.yml +++ b/.github/workflows/release-apple.yml @@ -194,6 +194,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json if git diff --staged --quiet; then @@ -216,6 +217,8 @@ jobs: case "$conflict_file" in openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json) ;; + packages/docs/src/generated/version-metadata.json) + ;; *) echo "❌ Unexpected conflict in $conflict_file" exit 1 @@ -228,6 +231,7 @@ jobs: jq --arg version "$VERSION" '.apple = $version' /tmp/upstream-openiap-versions.json > openiap-versions.json ./scripts/sync-versions.sh git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; } diff --git a/.github/workflows/release-google.yml b/.github/workflows/release-google.yml index 04d3445a8..1348f2284 100644 --- a/.github/workflows/release-google.yml +++ b/.github/workflows/release-google.yml @@ -180,6 +180,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json if git diff --staged --quiet; then @@ -202,6 +203,8 @@ jobs: case "$conflict_file" in openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json) ;; + packages/docs/src/generated/version-metadata.json) + ;; *) echo "❌ Unexpected conflict in $conflict_file" exit 1 @@ -214,6 +217,7 @@ jobs: jq --arg version "$VERSION" '.google = $version' /tmp/upstream-openiap-versions.json > openiap-versions.json ./scripts/sync-versions.sh git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; } diff --git a/.github/workflows/release-maui.yml b/.github/workflows/release-maui.yml index e01dbf931..c20fa174d 100644 --- a/.github/workflows/release-maui.yml +++ b/.github/workflows/release-maui.yml @@ -238,6 +238,10 @@ jobs: fi echo "Updated csproj package version to $VERSION" + - name: Sync generated version metadata + if: steps.version.outputs.skip_version_commit != 'true' + run: ./scripts/sync-versions.sh + - name: Commit version updates if: steps.version.outputs.skip_version_commit != 'true' env: @@ -247,6 +251,7 @@ jobs: git config user.email "github-actions[bot]@users.noreply.github.com" git add libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj + git add packages/docs/src/generated/version-metadata.json if git diff --staged --quiet; then echo "No version changes to commit" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ce0d7cdb3..8b67ef882 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -85,6 +85,7 @@ jobs: git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json git commit -m "chore(docs): bump version to $VERSION" if ! git pull --rebase origin main; then @@ -94,6 +95,8 @@ jobs: case "$conflict_file" in openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json) ;; + packages/docs/src/generated/version-metadata.json) + ;; *) echo "❌ Unexpected conflict in $conflict_file" exit 1 @@ -105,6 +108,7 @@ jobs: jq --arg version "$VERSION" '.spec = $version' /tmp/upstream-openiap-versions.json > openiap-versions.json ./scripts/sync-versions.sh git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; } diff --git a/scripts/audit-non-godot-parity.mjs b/scripts/audit-non-godot-parity.mjs index 46a026662..b74a45d17 100644 --- a/scripts/audit-non-godot-parity.mjs +++ b/scripts/audit-non-godot-parity.mjs @@ -1625,6 +1625,7 @@ function checkFrameworkDependencyHygiene() { "jq --arg version \"$VERSION\" '.spec = $version'", 'git show HEAD:openiap-versions.json > /tmp/upstream-openiap-versions.json', './scripts/sync-versions.sh', + 'packages/docs/src/generated/version-metadata.json', 'if git rev-parse "$TAG_NAME" >/dev/null 2>&1; then', 'Tag $TAG_NAME already exists', 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', @@ -1641,6 +1642,7 @@ function checkFrameworkDependencyHygiene() { 'git show HEAD:openiap-versions.json > /tmp/upstream-openiap-versions.json', 'Re-sync package metadata and docs copy after merge', './scripts/sync-versions.sh', + 'packages/docs/src/generated/version-metadata.json', 'packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json', ], `${releaseWorkflow} must commit package metadata synced from openiap-versions.json`); expectNotIncludes(releaseWorkflow, [ diff --git a/scripts/deploy.sh b/scripts/deploy.sh index aeb71e348..48290c447 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -114,9 +114,10 @@ if ! ./scripts/sync-versions.sh; then fi # Commit version changes if there are any -if [[ -n $(git status -s openiap-versions.json packages/*/openiap-versions.json packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json 2>/dev/null) ]]; then +if [[ -n $(git status -s openiap-versions.json packages/*/openiap-versions.json packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json packages/docs/src/generated/version-metadata.json 2>/dev/null) ]]; then echo -e "${BLUE}📝 Committing version changes...${NC}" git add openiap-versions.json packages/*/openiap-versions.json + git add packages/docs/src/generated/version-metadata.json git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json git commit -m "chore(spec): bump version to $VERSION" git pull --rebase origin main From f1e758747d30efbaa4cb01123bb22a27d9cd29ce Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 03:10:01 +0900 Subject: [PATCH 6/7] fix(google): preserve recovered subscription plan --- .../java/dev/hyo/openiap/OpenIapModule.kt | 12 +++++++++++- .../java/dev/hyo/openiap/helpers/Helpers.kt | 13 ++++++++----- .../dev/hyo/openiap/QueryPurchasesRaceTest.kt | 19 +++++++++++++++++++ 3 files changed, 38 insertions(+), 6 deletions(-) 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 4dc0e2ef9..b030361f7 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 @@ -1100,7 +1100,17 @@ class OpenIapModule( result.debugMessage ) OpenIapLog.d("ITEM_ALREADY_OWNED received; querying owned purchases for ${androidArgs.skus}", TAG) - queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus) { recovered -> + val basePlanIdsBySku = if (desiredType == BillingClient.ProductType.SUBS) { + details.associate { productDetails -> + productDetails.productId to productDetails.subscriptionOfferDetails + .orEmpty() + .firstOrNull() + ?.basePlanId + } + } else { + emptyMap() + } + queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus, basePlanIdsBySku) { recovered -> if (recovered.isNotEmpty()) { OpenIapLog.d("Recovered ${recovered.size} already-owned purchase(s)", TAG) notifySuspendedSubscriptions(recovered) 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 66f68e5c8..047a3ae08 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 @@ -78,6 +78,7 @@ internal fun queryAlreadyOwnedPurchases( client: BillingClient?, productType: String, skus: List, + basePlanIdsBySku: Map = emptyMap(), onResult: (List) -> Unit ) { val requestedSkus = skus.toSet() @@ -99,12 +100,14 @@ internal fun queryAlreadyOwnedPurchases( return@queryPurchasesAsync } - val recovered = purchaseList.orEmpty() - .map { billingPurchase -> billingPurchase.toPurchase(productType, null) } - .filter { purchase -> - purchase.productId in requestedSkus || - purchase.ids.orEmpty().any { id -> id in requestedSkus } + val recovered = purchaseList.orEmpty().mapNotNull { billingPurchase -> + val matchingSku = billingPurchase.products.firstOrNull { productId -> + productId in requestedSkus } + matchingSku?.let { sku -> + billingPurchase.toPurchase(productType, basePlanIdsBySku[sku]) + } + } onResult(recovered) } } 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 c53c074bd..0f6b5cde5 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 @@ -108,6 +108,25 @@ class QueryPurchasesRaceTest { assertEquals(listOf("requested-product"), recoveredProductIds) } + @Test + fun `queryAlreadyOwnedPurchases preserves requested subscription base plan`() { + val client = DuplicateBillingClient( + purchases = listOf(billingPurchase("subscription-product", "subscription-token")) + ) + val recoveredPlanIds = mutableListOf() + + queryAlreadyOwnedPurchases( + client, + BillingClient.ProductType.SUBS, + listOf("subscription-product"), + mapOf("subscription-product" to "premium-yearly") + ) { purchases -> + recoveredPlanIds += purchases.map { it.currentPlanId } + } + + assertEquals(listOf("premium-yearly"), recoveredPlanIds) + } + @Test fun `ProductManager getOrQuery tolerates duplicate concurrent callbacks`() = runTest { val client = DuplicateBillingClient() From ea62c92c38eb337a7443508a5017a8105118663f Mon Sep 17 00:00:00 2001 From: hyochan Date: Mon, 22 Jun 2026 12:34:57 +0900 Subject: [PATCH 7/7] fix(google): harden already-owned recovery --- .../java/dev/hyo/openiap/OpenIapModule.kt | 19 ++++++-- .../java/dev/hyo/openiap/helpers/Helpers.kt | 47 ++++++++++++++----- .../dev/hyo/openiap/QueryPurchasesRaceTest.kt | 46 +++++++++++++++++- 3 files changed, 95 insertions(+), 17 deletions(-) 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 b030361f7..35bbfce5a 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 @@ -48,12 +48,14 @@ import dev.hyo.openiap.SubscriptionPurchaseUpdatedHandler import dev.hyo.openiap.SubscriptionSubscriptionBillingIssueHandler import dev.hyo.openiap.VerifyPurchaseProps import dev.hyo.openiap.helpers.AndroidPurchaseArgs +import dev.hyo.openiap.helpers.SubscriptionBasePlanOffer import dev.hyo.openiap.helpers.onPurchaseError import dev.hyo.openiap.helpers.onPurchaseUpdated import dev.hyo.openiap.helpers.onSubscriptionBillingIssue import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases import dev.hyo.openiap.helpers.queryProductDetails import dev.hyo.openiap.helpers.queryPurchases +import dev.hyo.openiap.helpers.resolveBasePlanIdForOfferToken import dev.hyo.openiap.helpers.resumeGuard import dev.hyo.openiap.helpers.restorePurchases as restorePurchasesHelper import dev.hyo.openiap.helpers.toAndroidPurchaseArgs @@ -1102,10 +1104,21 @@ class OpenIapModule( OpenIapLog.d("ITEM_ALREADY_OWNED received; querying owned purchases for ${androidArgs.skus}", TAG) val basePlanIdsBySku = if (desiredType == BillingClient.ProductType.SUBS) { details.associate { productDetails -> - productDetails.productId to productDetails.subscriptionOfferDetails + val requestedOfferToken = androidArgs.subscriptionOffers + ?.find { it.sku == productDetails.productId } + ?.offerToken + val offers = productDetails.subscriptionOfferDetails .orEmpty() - .firstOrNull() - ?.basePlanId + .map { offer -> + SubscriptionBasePlanOffer( + offerToken = offer.offerToken, + basePlanId = offer.basePlanId + ) + } + productDetails.productId to resolveBasePlanIdForOfferToken( + offers, + requestedOfferToken + ) } } else { emptyMap() 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 047a3ae08..fa9f91166 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 @@ -92,26 +92,47 @@ internal fun queryAlreadyOwnedPurchases( .setProductType(productType) .build() - client.queryPurchasesAsync(params) { result, purchaseList -> - if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync + try { + client.queryPurchasesAsync(params) { result, purchaseList -> + if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync - if (result.responseCode != BillingClient.BillingResponseCode.OK) { - onResult(emptyList()) - return@queryPurchasesAsync - } - - val recovered = purchaseList.orEmpty().mapNotNull { billingPurchase -> - val matchingSku = billingPurchase.products.firstOrNull { productId -> - productId in requestedSkus + if (result.responseCode != BillingClient.BillingResponseCode.OK) { + onResult(emptyList()) + return@queryPurchasesAsync } - matchingSku?.let { sku -> - billingPurchase.toPurchase(productType, basePlanIdsBySku[sku]) + + val recovered = purchaseList.orEmpty().mapNotNull { billingPurchase -> + 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)) { + onResult(emptyList()) } - onResult(recovered) } } +internal data class SubscriptionBasePlanOffer( + val offerToken: String?, + val basePlanId: String? +) + +internal fun resolveBasePlanIdForOfferToken( + offers: List, + requestedOfferToken: String? +): String? { + return requestedOfferToken?.let { token -> + offers.find { it.offerToken == token }?.basePlanId + } + ?: offers.firstOrNull()?.basePlanId +} + internal suspend fun queryProductDetails( client: BillingClient?, productManager: ProductManager, 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 0f6b5cde5..87c329ee7 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 @@ -33,8 +33,10 @@ import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryProductDetailsResult import com.android.billingclient.api.QueryPurchasesParams import dev.hyo.openiap.helpers.ProductManager +import dev.hyo.openiap.helpers.SubscriptionBasePlanOffer import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases import dev.hyo.openiap.helpers.queryPurchases +import dev.hyo.openiap.helpers.resolveBasePlanIdForOfferToken import java.util.Collections import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit @@ -127,6 +129,44 @@ class QueryPurchasesRaceTest { assertEquals(listOf("premium-yearly"), recoveredPlanIds) } + @Test + fun `resolveBasePlanIdForOfferToken prefers requested offer token`() { + val offers = listOf( + SubscriptionBasePlanOffer( + offerToken = "monthly-offer-token", + basePlanId = "monthly" + ), + SubscriptionBasePlanOffer( + offerToken = "yearly-offer-token", + basePlanId = "yearly" + ) + ) + + assertEquals( + "yearly", + resolveBasePlanIdForOfferToken(offers, "yearly-offer-token") + ) + } + + @Test + fun `queryAlreadyOwnedPurchases completes when query throws`() { + val client = DuplicateBillingClient(throwsOnQueryPurchases = true) + val completions = AtomicInteger(0) + val recoveredSizes = mutableListOf() + + queryAlreadyOwnedPurchases( + client, + BillingClient.ProductType.INAPP, + listOf("product-id") + ) { purchases -> + completions.incrementAndGet() + recoveredSizes += purchases.size + } + + assertEquals(1, completions.get()) + assertEquals(listOf(0), recoveredSizes) + } + @Test fun `ProductManager getOrQuery tolerates duplicate concurrent callbacks`() = runTest { val client = DuplicateBillingClient() @@ -159,7 +199,8 @@ class QueryPurchasesRaceTest { ) private class DuplicateBillingClient( - private val purchases: List = emptyList() + private val purchases: List = emptyList(), + private val throwsOnQueryPurchases: Boolean = false ) : BillingClient() { val callbackFailures = Collections.synchronizedList(mutableListOf()) @@ -167,6 +208,9 @@ class QueryPurchasesRaceTest { params: QueryPurchasesParams, listener: PurchasesResponseListener ) { + if (throwsOnQueryPurchases) { + throw IllegalStateException("query failed") + } val result = BillingResult.newBuilder() .setResponseCode(BillingResponseCode.OK) .build()