Skip to content

Commit 6ed7b4a

Browse files
authored
fix(google): recover already-owned purchases
Recover Android Play Billing ITEM_ALREADY_OWNED results by querying currently owned purchases for the in-flight SKU/type and re-publishing matching purchases through purchase update listeners. Also hardens listener concurrency, preserves recovered subscription basePlanId by matching the requested offer token, and syncs generated version metadata in CI/release workflows. Closes #166
1 parent 1259ac2 commit 6ed7b4a

10 files changed

Lines changed: 279 additions & 9 deletions

File tree

.github/workflows/ci.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,9 @@ jobs:
8080
with:
8181
node-version: 20
8282

83+
- name: Sync generated version files
84+
run: ./scripts/sync-versions.sh
85+
8386
- name: Run non-Godot SDK parity audit
8487
run: node scripts/audit-non-godot-parity.mjs
8588

.github/workflows/release-apple.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,7 @@ jobs:
194194
git config user.email "github-actions[bot]@users.noreply.github.com"
195195
196196
git add openiap-versions.json packages/*/openiap-versions.json
197+
git add packages/docs/src/generated/version-metadata.json
197198
git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json
198199
199200
if git diff --staged --quiet; then
@@ -216,6 +217,8 @@ jobs:
216217
case "$conflict_file" in
217218
openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json)
218219
;;
220+
packages/docs/src/generated/version-metadata.json)
221+
;;
219222
*)
220223
echo "❌ Unexpected conflict in $conflict_file"
221224
exit 1
@@ -228,6 +231,7 @@ jobs:
228231
jq --arg version "$VERSION" '.apple = $version' /tmp/upstream-openiap-versions.json > openiap-versions.json
229232
./scripts/sync-versions.sh
230233
git add openiap-versions.json packages/*/openiap-versions.json
234+
git add packages/docs/src/generated/version-metadata.json
231235
git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json
232236
233237
GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; }

.github/workflows/release-google.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ jobs:
180180
git config user.email "github-actions[bot]@users.noreply.github.com"
181181
182182
git add openiap-versions.json packages/*/openiap-versions.json
183+
git add packages/docs/src/generated/version-metadata.json
183184
git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json
184185
185186
if git diff --staged --quiet; then
@@ -202,6 +203,8 @@ jobs:
202203
case "$conflict_file" in
203204
openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json)
204205
;;
206+
packages/docs/src/generated/version-metadata.json)
207+
;;
205208
*)
206209
echo "❌ Unexpected conflict in $conflict_file"
207210
exit 1
@@ -214,6 +217,7 @@ jobs:
214217
jq --arg version "$VERSION" '.google = $version' /tmp/upstream-openiap-versions.json > openiap-versions.json
215218
./scripts/sync-versions.sh
216219
git add openiap-versions.json packages/*/openiap-versions.json
220+
git add packages/docs/src/generated/version-metadata.json
217221
git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json
218222
219223
GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; }

.github/workflows/release-maui.yml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,10 @@ jobs:
238238
fi
239239
echo "Updated csproj package version to $VERSION"
240240
241+
- name: Sync generated version metadata
242+
if: steps.version.outputs.skip_version_commit != 'true'
243+
run: ./scripts/sync-versions.sh
244+
241245
- name: Commit version updates
242246
if: steps.version.outputs.skip_version_commit != 'true'
243247
env:
@@ -247,6 +251,7 @@ jobs:
247251
git config user.email "github-actions[bot]@users.noreply.github.com"
248252
249253
git add libraries/maui-iap/src/OpenIap.Maui/OpenIap.Maui.csproj
254+
git add packages/docs/src/generated/version-metadata.json
250255
251256
if git diff --staged --quiet; then
252257
echo "No version changes to commit"

.github/workflows/release.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ jobs:
8585
git config user.name "github-actions[bot]"
8686
git config user.email "github-actions[bot]@users.noreply.github.com"
8787
git add openiap-versions.json packages/*/openiap-versions.json
88+
git add packages/docs/src/generated/version-metadata.json
8889
git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json
8990
git commit -m "chore(docs): bump version to $VERSION"
9091
if ! git pull --rebase origin main; then
@@ -94,6 +95,8 @@ jobs:
9495
case "$conflict_file" in
9596
openiap-versions.json|packages/*/openiap-versions.json|packages/gql/package.json|packages/docs/package.json|packages/google/package.json|packages/apple/package.json)
9697
;;
98+
packages/docs/src/generated/version-metadata.json)
99+
;;
97100
*)
98101
echo "❌ Unexpected conflict in $conflict_file"
99102
exit 1
@@ -105,6 +108,7 @@ jobs:
105108
jq --arg version "$VERSION" '.spec = $version' /tmp/upstream-openiap-versions.json > openiap-versions.json
106109
./scripts/sync-versions.sh
107110
git add openiap-versions.json packages/*/openiap-versions.json
111+
git add packages/docs/src/generated/version-metadata.json
108112
git add packages/gql/package.json packages/docs/package.json packages/google/package.json packages/apple/package.json
109113
110114
GIT_EDITOR=true git rebase --continue || { echo "❌ Rebase continue failed"; exit 1; }

packages/google/openiap/src/play/java/dev/hyo/openiap/OpenIapModule.kt

Lines changed: 54 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,14 @@ import dev.hyo.openiap.SubscriptionPurchaseUpdatedHandler
4848
import dev.hyo.openiap.SubscriptionSubscriptionBillingIssueHandler
4949
import dev.hyo.openiap.VerifyPurchaseProps
5050
import dev.hyo.openiap.helpers.AndroidPurchaseArgs
51+
import dev.hyo.openiap.helpers.SubscriptionBasePlanOffer
5152
import dev.hyo.openiap.helpers.onPurchaseError
5253
import dev.hyo.openiap.helpers.onPurchaseUpdated
5354
import dev.hyo.openiap.helpers.onSubscriptionBillingIssue
55+
import dev.hyo.openiap.helpers.queryAlreadyOwnedPurchases
5456
import dev.hyo.openiap.helpers.queryProductDetails
5557
import dev.hyo.openiap.helpers.queryPurchases
58+
import dev.hyo.openiap.helpers.resolveBasePlanIdForOfferToken
5659
import dev.hyo.openiap.helpers.resumeGuard
5760
import dev.hyo.openiap.helpers.restorePurchases as restorePurchasesHelper
5861
import dev.hyo.openiap.helpers.toAndroidPurchaseArgs
@@ -107,10 +110,10 @@ class OpenIapModule(
107110
private val gson = Gson()
108111
private val fallbackActivity: Activity? = if (context is Activity) context else null
109112

110-
private val purchaseUpdateListeners = mutableSetOf<OpenIapPurchaseUpdateListener>()
111-
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
112-
private val userChoiceBillingListeners = mutableSetOf<OpenIapUserChoiceBillingListener>()
113-
private val developerProvidedBillingListeners = mutableSetOf<OpenIapDeveloperProvidedBillingListener>()
113+
private val purchaseUpdateListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapPurchaseUpdateListener>()
114+
private val purchaseErrorListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapPurchaseErrorListener>()
115+
private val userChoiceBillingListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapUserChoiceBillingListener>()
116+
private val developerProvidedBillingListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapDeveloperProvidedBillingListener>()
114117
// Thread-safe: listeners can be added/removed on the main thread while
115118
// notifySuspendedSubscriptions iterates from Dispatchers.IO.
116119
private val subscriptionBillingIssueListeners =
@@ -901,6 +904,8 @@ class OpenIapModule(
901904
return@withContext emptyList()
902905
}
903906

907+
val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
908+
904909
suspendCancellableCoroutine<List<Purchase>> { continuation ->
905910
var callbackRef: ((Result<List<Purchase>>) -> Unit)? = null
906911
val resumer = continuation.resumeGuard {
@@ -922,8 +927,6 @@ class OpenIapModule(
922927
return@suspendCancellableCoroutine
923928
}
924929

925-
val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP
926-
927930
val detailsBySku = mutableMapOf<String, ProductDetails>()
928931
for (sku in androidArgs.skus) {
929932
productManager.get(sku)?.takeIf { it.productType == desiredType }?.let { detailsBySku[sku] = it }
@@ -1093,6 +1096,51 @@ class OpenIapModule(
10931096
val result = client.launchBillingFlow(activity, flowBuilder.build())
10941097
OpenIapLog.d("launchBillingFlow result: ${result.responseCode} - ${result.debugMessage}", TAG)
10951098
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
1099+
if (result.responseCode == BillingClient.BillingResponseCode.ITEM_ALREADY_OWNED) {
1100+
val err = OpenIapError.fromBillingResponseCode(
1101+
result.responseCode,
1102+
result.debugMessage
1103+
)
1104+
OpenIapLog.d("ITEM_ALREADY_OWNED received; querying owned purchases for ${androidArgs.skus}", TAG)
1105+
val basePlanIdsBySku = if (desiredType == BillingClient.ProductType.SUBS) {
1106+
details.associate { productDetails ->
1107+
val requestedOfferToken = androidArgs.subscriptionOffers
1108+
?.find { it.sku == productDetails.productId }
1109+
?.offerToken
1110+
val offers = productDetails.subscriptionOfferDetails
1111+
.orEmpty()
1112+
.map { offer ->
1113+
SubscriptionBasePlanOffer(
1114+
offerToken = offer.offerToken,
1115+
basePlanId = offer.basePlanId
1116+
)
1117+
}
1118+
productDetails.productId to resolveBasePlanIdForOfferToken(
1119+
offers,
1120+
requestedOfferToken
1121+
)
1122+
}
1123+
} else {
1124+
emptyMap()
1125+
}
1126+
queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus, basePlanIdsBySku) { recovered ->
1127+
if (recovered.isNotEmpty()) {
1128+
OpenIapLog.d("Recovered ${recovered.size} already-owned purchase(s)", TAG)
1129+
notifySuspendedSubscriptions(recovered)
1130+
for (purchase in recovered) {
1131+
for (listener in purchaseUpdateListeners) {
1132+
runCatching { listener.onPurchaseUpdated(purchase) }
1133+
}
1134+
}
1135+
consumePurchaseCallback(Result.success(recovered))
1136+
} else {
1137+
OpenIapLog.w("ITEM_ALREADY_OWNED recovery found no matching owned purchases", TAG)
1138+
for (listener in purchaseErrorListeners) { runCatching { listener.onPurchaseError(err) } }
1139+
consumePurchaseCallback(Result.success(emptyList()))
1140+
}
1141+
}
1142+
return
1143+
}
10961144
val err = when (result.responseCode) {
10971145
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
10981146
OpenIapLog.w("DEVELOPER_ERROR: Invalid arguments. Check if subscriptions are in the same group.", TAG)

packages/google/openiap/src/play/java/dev/hyo/openiap/helpers/Helpers.kt

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import dev.hyo.openiap.OpenIapError
77
import dev.hyo.openiap.Purchase
88
import dev.hyo.openiap.utils.BillingConverters.toPurchase
99
import kotlinx.coroutines.suspendCancellableCoroutine
10+
import java.util.concurrent.atomic.AtomicBoolean
1011

1112
// Common helpers (onPurchaseUpdated, onPurchaseError, AndroidPurchaseArgs,
1213
// toAndroidPurchaseArgs, toPurchaseError) are in main/helpers/CommonHelpers.kt
@@ -69,6 +70,69 @@ internal suspend fun queryPurchases(
6970
}
7071
}
7172

73+
/**
74+
* Queries Play Billing directly after ITEM_ALREADY_OWNED and returns only
75+
* currently owned purchases that match the in-flight request SKUs.
76+
*/
77+
internal fun queryAlreadyOwnedPurchases(
78+
client: BillingClient?,
79+
productType: String,
80+
skus: List<String>,
81+
basePlanIdsBySku: Map<String, String?> = emptyMap(),
82+
onResult: (List<Purchase>) -> Unit
83+
) {
84+
val requestedSkus = skus.toSet()
85+
if (client == null || requestedSkus.isEmpty()) {
86+
onResult(emptyList())
87+
return
88+
}
89+
90+
val didHandleResult = AtomicBoolean(false)
91+
val params = QueryPurchasesParams.newBuilder()
92+
.setProductType(productType)
93+
.build()
94+
95+
try {
96+
client.queryPurchasesAsync(params) { result, purchaseList ->
97+
if (!didHandleResult.compareAndSet(false, true)) return@queryPurchasesAsync
98+
99+
if (result.responseCode != BillingClient.BillingResponseCode.OK) {
100+
onResult(emptyList())
101+
return@queryPurchasesAsync
102+
}
103+
104+
val recovered = purchaseList.orEmpty().mapNotNull { billingPurchase ->
105+
val matchingSku = billingPurchase.products.firstOrNull { productId ->
106+
productId in requestedSkus
107+
}
108+
matchingSku?.let { sku ->
109+
billingPurchase.toPurchase(productType, basePlanIdsBySku[sku])
110+
}
111+
}
112+
onResult(recovered)
113+
}
114+
} catch (_: Exception) {
115+
if (didHandleResult.compareAndSet(false, true)) {
116+
onResult(emptyList())
117+
}
118+
}
119+
}
120+
121+
internal data class SubscriptionBasePlanOffer(
122+
val offerToken: String?,
123+
val basePlanId: String?
124+
)
125+
126+
internal fun resolveBasePlanIdForOfferToken(
127+
offers: List<SubscriptionBasePlanOffer>,
128+
requestedOfferToken: String?
129+
): String? {
130+
return requestedOfferToken?.let { token ->
131+
offers.find { it.offerToken == token }?.basePlanId
132+
}
133+
?: offers.firstOrNull()?.basePlanId
134+
}
135+
72136
internal suspend fun queryProductDetails(
73137
client: BillingClient?,
74138
productManager: ProductManager,

0 commit comments

Comments
 (0)