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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release-apple.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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; }
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release-google.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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; }
Expand Down
5 changes: 5 additions & 0 deletions .github/workflows/release-maui.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +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
Expand Down Expand Up @@ -107,10 +110,10 @@ class OpenIapModule(
private val gson = Gson()
private val fallbackActivity: Activity? = if (context is Activity) context else null

private val purchaseUpdateListeners = mutableSetOf<OpenIapPurchaseUpdateListener>()
private val purchaseErrorListeners = mutableSetOf<OpenIapPurchaseErrorListener>()
private val userChoiceBillingListeners = mutableSetOf<OpenIapUserChoiceBillingListener>()
private val developerProvidedBillingListeners = mutableSetOf<OpenIapDeveloperProvidedBillingListener>()
private val purchaseUpdateListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapPurchaseUpdateListener>()
Comment thread
hyochan marked this conversation as resolved.
private val purchaseErrorListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapPurchaseErrorListener>()
private val userChoiceBillingListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapUserChoiceBillingListener>()
private val developerProvidedBillingListeners = java.util.concurrent.CopyOnWriteArraySet<OpenIapDeveloperProvidedBillingListener>()
// Thread-safe: listeners can be added/removed on the main thread while
// notifySuspendedSubscriptions iterates from Dispatchers.IO.
private val subscriptionBillingIssueListeners =
Expand Down Expand Up @@ -901,6 +904,8 @@ class OpenIapModule(
return@withContext emptyList()
}

val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP

suspendCancellableCoroutine<List<Purchase>> { continuation ->
var callbackRef: ((Result<List<Purchase>>) -> Unit)? = null
val resumer = continuation.resumeGuard {
Expand All @@ -922,8 +927,6 @@ class OpenIapModule(
return@suspendCancellableCoroutine
}

val desiredType = if (androidArgs.type == ProductQueryType.Subs) BillingClient.ProductType.SUBS else BillingClient.ProductType.INAPP

val detailsBySku = mutableMapOf<String, ProductDetails>()
for (sku in androidArgs.skus) {
productManager.get(sku)?.takeIf { it.productType == desiredType }?.let { detailsBySku[sku] = it }
Expand Down Expand Up @@ -1093,6 +1096,51 @@ 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)
val basePlanIdsBySku = if (desiredType == BillingClient.ProductType.SUBS) {
details.associate { productDetails ->
val requestedOfferToken = androidArgs.subscriptionOffers
?.find { it.sku == productDetails.productId }
?.offerToken
val offers = productDetails.subscriptionOfferDetails
.orEmpty()
.map { offer ->
SubscriptionBasePlanOffer(
offerToken = offer.offerToken,
basePlanId = offer.basePlanId
)
}
productDetails.productId to resolveBasePlanIdForOfferToken(
offers,
requestedOfferToken
)
}
} else {
emptyMap()
}
Comment thread
hyochan marked this conversation as resolved.
queryAlreadyOwnedPurchases(client, desiredType, androidArgs.skus, basePlanIdsBySku) { recovered ->
if (recovered.isNotEmpty()) {
OpenIapLog.d("Recovered ${recovered.size} already-owned purchase(s)", TAG)
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) } }
consumePurchaseCallback(Result.success(emptyList()))
}
}
return
}
Comment thread
hyochan marked this conversation as resolved.
val err = when (result.responseCode) {
BillingClient.BillingResponseCode.DEVELOPER_ERROR -> {
OpenIapLog.w("DEVELOPER_ERROR: Invalid arguments. Check if subscriptions are in the same group.", TAG)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -69,6 +70,69 @@ 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<String>,
basePlanIdsBySku: Map<String, String?> = emptyMap(),
onResult: (List<Purchase>) -> Unit
) {
val requestedSkus = skus.toSet()
if (client == null || requestedSkus.isEmpty()) {
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
}

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())
}
}
}

internal data class SubscriptionBasePlanOffer(
val offerToken: String?,
val basePlanId: String?
)

internal fun resolveBasePlanIdForOfferToken(
offers: List<SubscriptionBasePlanOffer>,
requestedOfferToken: String?
): String? {
return requestedOfferToken?.let { token ->
offers.find { it.offerToken == token }?.basePlanId
}
?: offers.firstOrNull()?.basePlanId
}

internal suspend fun queryProductDetails(
client: BillingClient?,
productManager: ProductManager,
Expand Down
Loading